@bridge_gpt/mcp-server 0.2.57 → 0.2.59

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 (90) hide show
  1. package/README.md +112 -113
  2. package/build/agent-registry.js +15 -0
  3. package/build/agent-utils.js +16 -8
  4. package/build/claude-user-config-doctor.js +1 -35
  5. package/build/codex-bridge-profile.js +5 -1
  6. package/build/codex-launch-scope.js +13 -2
  7. package/build/commands.generated.js +5 -6
  8. package/build/conductor/bridge-api-client.js +149 -0
  9. package/build/conductor/bring-up-facts.js +1 -1
  10. package/build/conductor/claude-hook.js +16 -16
  11. package/build/conductor/doctor.js +24 -19
  12. package/build/conductor/epic-implementer-cli.js +38 -5
  13. package/build/conductor/epic-reconcile.js +1 -1
  14. package/build/conductor/epic-runtime.js +10 -10
  15. package/build/conductor/errors.js +3 -3
  16. package/build/conductor/file-scope-guard.js +4 -4
  17. package/build/conductor/git-ci-types.js +1 -1
  18. package/build/conductor/git-inspection.js +2 -2
  19. package/build/conductor/install-doctor.js +6 -6
  20. package/build/conductor/merge-ledger.js +2 -2
  21. package/build/conductor/pr-ci-producer.js +6 -6
  22. package/build/conductor/pr-review-producer.js +2 -2
  23. package/build/conductor/producer-ledger.js +2 -0
  24. package/build/conductor/readiness-sections.js +7 -7
  25. package/build/conductor/readiness.js +2 -2
  26. package/build/conductor/recovery-cli.js +243 -12
  27. package/build/conductor/recovery-operations.js +56 -4
  28. package/build/conductor/repair-contract.js +38 -4
  29. package/build/conductor/spec-review-producer.js +6 -6
  30. package/build/conductor/store.js +10 -10
  31. package/build/conductor/supervisor-config.js +14 -14
  32. package/build/conductor/supervisor-ledger.js +2 -2
  33. package/build/conductor/supervisor-message-relay.js +2 -2
  34. package/build/conductor/worker-ledger-cli.js +19 -19
  35. package/build/conductor-bundle-artifacts.js +2 -2
  36. package/build/cursor-agent-progress.js +72 -0
  37. package/build/doctor.js +20 -296
  38. package/build/drive-epic.js +65 -71
  39. package/build/epic-implementer-bin.js +22 -17
  40. package/build/epic-implementer-claude-hook-bin.js +4 -2
  41. package/build/epic-implementer-env.js +155 -0
  42. package/build/epic-integration-pr.js +2 -2
  43. package/build/epic-shared/bridge-client.js +773 -0
  44. package/build/epic-shared/cut-protocol.js +392 -0
  45. package/build/executor/env.js +19 -8
  46. package/build/executor/heartbeat.js +1 -1
  47. package/build/executor/job-errors.js +3 -3
  48. package/build/executor/job-runner.js +1 -1
  49. package/build/executor/merge-job.js +35 -3
  50. package/build/executor/observation.js +1 -1
  51. package/build/executor/preflight.js +1 -1
  52. package/build/executor/prompt-spec.js +1 -1
  53. package/build/executor/reconcile-overlap-job.js +10 -130
  54. package/build/executor/reconcile-overlap-types.js +1 -1
  55. package/build/executor/runner.js +1 -1
  56. package/build/implement-epic/cli.js +1 -1
  57. package/build/index.js +187 -224
  58. package/build/init.js +20 -28
  59. package/build/install-bridge-conductor.js +7 -7
  60. package/build/install-bridge.js +203 -10
  61. package/build/install-doctor.js +1 -1
  62. package/build/mcp-host-entry-adapter.js +6 -0
  63. package/build/mcp-host-targets.js +2 -0
  64. package/build/mcp-invoke.js +5 -5
  65. package/build/mcp-profile.js +2 -2
  66. package/build/mcp-provisioning.js +2 -2
  67. package/build/mcp-server-invocation.js +3 -3
  68. package/build/merge-pull-request.js +15 -0
  69. package/build/model-routing-host.js +25 -0
  70. package/build/model-routing-preferences.js +99 -0
  71. package/build/pipelines.generated.js +11 -11
  72. package/build/plan-epic-implementer-eligibility.js +215 -0
  73. package/build/plan-execution-ledger.js +8 -0
  74. package/build/plane/member-roster.js +5 -5
  75. package/build/plane/supervisor.js +1 -1
  76. package/build/plane/types.js +5 -5
  77. package/build/readiness-check.js +1 -1
  78. package/build/readme.generated.js +1 -1
  79. package/build/review-tickets.js +4 -4
  80. package/build/setup-epic.js +8 -8
  81. package/build/start-tickets-conductor.js +32 -32
  82. package/build/start-tickets-prereqs.js +4 -9
  83. package/build/start-tickets.js +494 -98
  84. package/build/version.generated.js +3 -3
  85. package/docs/CONDUCTOR.md +69 -18
  86. package/docs/install/mcp-tool-integrations.md +2 -1
  87. package/package.json +2 -2
  88. package/pipelines/implement-ticket.json +2 -2
  89. package/pipelines/plan-epic.json +2 -2
  90. package/pipelines/review-ticket.json +1 -1
package/build/init.js CHANGED
@@ -818,41 +818,33 @@ export async function runInit(cwd, options = {}) {
818
818
  // ---- Phase 4a: Scaffold GitHub Copilot command artifacts ----
819
819
  // VS Code Local agent discovers prompt files; Agent Host discovers project
820
820
  // skills. Both artifacts are rendered from COMMANDS above, never a second
821
- // Copilot-specific command catalog.
821
+ // Copilot-specific command catalog. Each representation reports its own
822
+ // counts, so a written prompt never implies its skill exists (or vice versa).
822
823
  if (ideDetection.vscode) {
823
824
  const promptsDir = path.join(cwd, ".github", "prompts");
824
825
  const skillsDir = path.join(cwd, ".github", "skills");
825
- const copilotWritten = new Set();
826
- const copilotSkipped = new Set();
827
- const copilotOverwritten = new Set();
828
826
  const artifacts = commandEntries.map(([filename, content]) => ({
829
- filename,
830
827
  commandName: filename.replace(/\.md$/, ""),
831
828
  ...translateCommandToCopilot(filename, content),
832
829
  }));
833
- const outcomes = await Promise.all(artifacts.flatMap(({ filename, commandName, prompt, skill }) => [
834
- scaffoldFile(path.join(promptsDir, filename.replace(/\.md$/, ".prompt.md")), prompt, promptsDir),
835
- scaffoldFile(path.join(skillsDir, commandName, "SKILL.md"), skill, path.join(skillsDir, commandName)),
836
- ]));
837
- artifacts.forEach(({ commandName }, i) => {
838
- const commandOutcomes = outcomes.slice(i * 2, i * 2 + 2);
839
- if (commandOutcomes.every((outcome) => outcome === "skipped")) {
840
- copilotSkipped.add(commandName);
841
- return;
842
- }
843
- if (commandOutcomes.some((outcome) => outcome === "overwritten")) {
844
- copilotOverwritten.add(commandName);
845
- }
846
- copilotWritten.add(commandName);
847
- });
848
- console.log(`\nCopilot workflows: scaffolded ${total} prompt files and ${total} Agent Host skills`);
849
- if (copilotWritten.size > 0)
850
- console.log(` Written: ${copilotWritten.size}`);
851
- if (copilotOverwritten.size > 0)
852
- console.log(` Overwritten (content changed): ${copilotOverwritten.size}`);
853
- if (copilotSkipped.size > 0)
854
- console.log(` Skipped (unchanged): ${copilotSkipped.size}`);
855
- console.log(" .github/prompts/ (VS Code Local agent) and .github/skills/ (Agent Host)");
830
+ const promptOutcomes = await Promise.all(artifacts.map(({ commandName, prompt }) => scaffoldFile(path.join(promptsDir, `${commandName}.prompt.md`), prompt, promptsDir)));
831
+ const skillOutcomes = await Promise.all(artifacts.map(({ commandName, skill }) => scaffoldFile(path.join(skillsDir, commandName, "SKILL.md"), skill, path.join(skillsDir, commandName))));
832
+ const copilotReports = [
833
+ ["VS Code Local-agent prompt files", ".github/prompts/", promptOutcomes],
834
+ ["Agent Host skills", ".github/skills/", skillOutcomes],
835
+ ];
836
+ for (const [label, dir, outcomes] of copilotReports) {
837
+ const written = outcomes.filter((outcome) => outcome !== "skipped").length;
838
+ const overwritten = outcomes.filter((outcome) => outcome === "overwritten").length;
839
+ const skipped = outcomes.filter((outcome) => outcome === "skipped").length;
840
+ console.log(`\n${label}: scaffolded ${outcomes.length} into ${dir}`);
841
+ if (written > 0)
842
+ console.log(` Written: ${written}`);
843
+ if (overwritten > 0)
844
+ console.log(` Overwritten (content changed): ${overwritten}`);
845
+ if (skipped > 0)
846
+ console.log(` Skipped (unchanged): ${skipped}`);
847
+ }
856
848
  }
857
849
  // ---- Phase 4b: Scaffold shipped documentation assets ----
858
850
  // Every doc asset the /install-bridge capability report points a human at,
@@ -136,7 +136,7 @@ export function getInstallBridgeConductorUsage() {
136
136
  "production when unset). The bootstrap POST then asks its own, separate write",
137
137
  "consent. Without a TTY there is no consent, so the command aborts.",
138
138
  "",
139
- "Tool visibility (its own consent): adds the `conductor` token to the",
139
+ "Tool visibility (its own consent): adds the `epic_implementer` token to the",
140
140
  "BRIDGE_MCP_PROFILE of the Bridge entry in host MCP configurations that",
141
141
  "ALREADY have one. Automatic merging covers .mcp.json, .vscode/mcp.json, and",
142
142
  ".cursor/mcp.json only — never Codex or Copilot, which are neither inspected",
@@ -556,10 +556,10 @@ async function runExecutorProvisioningPhase(deps, executor, plans, access) {
556
556
  * said nothing would reasonably conclude the tools were already visible.
557
557
  */
558
558
  export function formatConductorRestartAdvisory(mergedProfile) {
559
- return (`⚠️ Conductor profile updated: BRIDGE_MCP_PROFILE is now set to \`${mergedProfile}\` ` +
559
+ return (`⚠️ Epic Implementer profile updated: BRIDGE_MCP_PROFILE is now set to \`${mergedProfile}\` ` +
560
560
  "in your local MCP config file(s). The running MCP server captured its active groups " +
561
561
  "at startup, so this activates on the next MCP server launch — restart your MCP " +
562
- "client to gain access to the conductor tools.");
562
+ "client to gain access to the Epic Implementer tools.");
563
563
  }
564
564
  /** Remediation printed when no supported host config carries a Bridge entry. */
565
565
  export const CONDUCTOR_PROFILE_EMPTY_MERGE_REMEDIATION = `run \`npx ${MCP_PACKAGE_NAME} install\` to create and register the host MCP entry, ` +
@@ -570,14 +570,14 @@ export const CONDUCTOR_HOOKS_MANUAL_REMEDIATION = "re-run `install conductor` an
570
570
  /**
571
571
  * Phase: tool visibility.
572
572
  *
573
- * Adds the `conductor` token to EXISTING supported host MCP entries behind its
573
+ * Adds the `epic_implementer` token to EXISTING supported host MCP entries behind its
574
574
  * own consent, then prints the restart advisory. It never creates a host config
575
575
  * — the merge helper's never-create rule is what keeps a declined or
576
576
  * unconfigured repository from acquiring files it did not ask for.
577
577
  */
578
578
  async function runConductorToolVisibilityPhase(deps, local) {
579
579
  const consented = await confirm(deps, "Update existing supported host MCP configurations (.mcp.json, .vscode/mcp.json, " +
580
- ".cursor/mcp.json) to add the `conductor` profile? [y/N]: ");
580
+ ".cursor/mcp.json) to add the `epic_implementer` profile? [y/N]: ");
581
581
  if (!consented) {
582
582
  deps.log(" declined — no host MCP configuration and no install state were written.");
583
583
  return {
@@ -620,7 +620,7 @@ async function runConductorToolVisibilityPhase(deps, local) {
620
620
  }
621
621
  deps.log(carrying.length > 0
622
622
  ? ` updated: ${carrying.join(", ")}`
623
- : " the conductor profile was merged into your supported host MCP config(s).");
623
+ : " the Epic Implementer profile was merged into your supported host MCP config(s).");
624
624
  // Coverage honesty: the merge path writes these three files and no others.
625
625
  deps.log(" Codex and Copilot host configuration was NOT inspected or changed.");
626
626
  deps.log(` ${formatConductorRestartAdvisory(mergedProfile)}`);
@@ -1027,7 +1027,7 @@ export async function runInstallBridgeConductorCli(argv, deps) {
1027
1027
  else {
1028
1028
  deps.log(" skipped — this caller supplied no local-provisioning seams.");
1029
1029
  manualActions.push("tool visibility: run the packaged `install conductor` command to add the " +
1030
- "`conductor` profile to your host MCP configuration.");
1030
+ "`epic_implementer` profile to your host MCP configuration.");
1031
1031
  }
1032
1032
  deps.log("");
1033
1033
  deps.log("Phase 5/8 — conductor local observability…");
@@ -167,7 +167,7 @@ import { executorLaunchdLabelForId, executorSystemdUnitNameForId, executorLaunch
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
- import { runConductorInstallDoctor, CONDUCTOR_PROFILE_TOKEN, } from "./conductor/install-doctor.js";
170
+ import { runConductorInstallDoctor, EPIC_IMPLEMENTER_PROFILE_TOKEN, } from "./conductor/install-doctor.js";
171
171
  import { resolveConductorBridgeApiAccess, } from "./conductor/bridge-api-client.js";
172
172
  import { claudeReviewWorkflowPath, writeClaudeReviewWorkflow, } from "./claude-review-workflow.js";
173
173
  // BAPI-941: read-only workflow-lineage probe, wired into the conductor install
@@ -300,7 +300,7 @@ export const INSTALL_BRIDGE_AGENT_PROMPT = "Run /install-bridge now, with no arg
300
300
  "duplicate. This silence applies ONLY to success and ONLY in the install-spawn context: a Stage 6 " +
301
301
  "FAILURE is still reported loudly and in full, and in JOIN MODE or a direct/manual /install-bridge " +
302
302
  "invocation Stage 6 reports its success exactly as it does today. " +
303
- "The install-spawn skip set is exactly Stage 8, Stage 9, and Stage 10. The command-owned Stage 11 " +
303
+ "The install-spawn skip set is exactly Stage 8 and Stage 9. The command-owned Stage 11 " +
304
304
  "(invite teammates) is NOT in that skip set: it is independently gated and may run only when the " +
305
305
  "caller's role is admin AND customer_type is b2b AND an interactive response is available; " +
306
306
  "otherwise it is skipped silently. If Stage 11 mints a teammate key, show that plaintext key " +
@@ -520,7 +520,8 @@ export function getInstallBridgeUsage(baseUrl = DEFAULT_BAPI_BASE_URL) {
520
520
  " launches nothing.",
521
521
  " --codex-profile on|off Opt in to Bridge workflow MCP approvals for",
522
522
  " Codex workers, or explicitly decline. With a",
523
- " Codex TTY selection, the installer asks.",
523
+ " Codex TTY selection, the installer asks",
524
+ " with Yes as the default (press Enter).",
524
525
  " --force Overwrite an existing real BAPI_API_KEY in a",
525
526
  " host config (or in the credential store) without",
526
527
  " prompting. It does NOT bypass the git-tracked",
@@ -571,7 +572,7 @@ export function getInstallBridgeUsage(baseUrl = DEFAULT_BAPI_BASE_URL) {
571
572
  " (its own separate consent), then closes the local",
572
573
  " out-of-the-box gap in two more phases, each behind",
573
574
  " its OWN consent: tool visibility adds the",
574
- " `conductor` token to existing .mcp.json /",
575
+ " `epic_implementer` token to existing .mcp.json /",
575
576
  " .vscode/mcp.json / .cursor/mcp.json entries (never",
576
577
  " Codex or Copilot, and never creating a file) and",
577
578
  " prints the MCP-client restart advisory; local",
@@ -1122,9 +1123,9 @@ export function buildAgentNotesSummaryLine(offer, result, writeFailed, unsupport
1122
1123
  * Exported so `index.ts`'s `--init` can share the exact same prompt mechanics
1123
1124
  * as `install`'s missing-project-root confirmation (BAPI-818, R1).
1124
1125
  */
1125
- export function promptLineViaReadline(promptText) {
1126
+ export function promptLineViaReadline(promptText, input = process.stdin, output = process.stderr) {
1126
1127
  return new Promise((resolve) => {
1127
- const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
1128
+ const rl = readline.createInterface({ input, output });
1128
1129
  // See promptSecretViaReadline: EOF must resolve rather than deadlock the
1129
1130
  // top-level await, and `answered` guards the synchronous `close` from
1130
1131
  // discarding a real answer.
@@ -1453,8 +1454,8 @@ function buildConductorExecutorDeps(deps, baseUrl) {
1453
1454
  */
1454
1455
  function buildConductorLocalDeps(deps) {
1455
1456
  return {
1456
- mergeProfileToken: () => mergeBridgeApiProfileToken(deps.cwd, CONDUCTOR_PROFILE_TOKEN),
1457
- inspectProfileToken: () => inspectBridgeApiProfileToken(deps.cwd, CONDUCTOR_PROFILE_TOKEN, {
1457
+ mergeProfileToken: () => mergeBridgeApiProfileToken(deps.cwd, EPIC_IMPLEMENTER_PROFILE_TOKEN),
1458
+ inspectProfileToken: () => inspectBridgeApiProfileToken(deps.cwd, EPIC_IMPLEMENTER_PROFILE_TOKEN, {
1458
1459
  readFile: deps.readFile,
1459
1460
  }),
1460
1461
  // The repository root is this command's cwd, which is what the hook
@@ -2012,6 +2013,174 @@ export function promptMultiSelectViaReadline(promptText, options, input = proces
2012
2013
  prompt();
2013
2014
  });
2014
2015
  }
2016
+ /**
2017
+ * Subscription display name for the three platforms whose maximize-posture
2018
+ * option-three wording names the actual tool, keyed by the SAME
2019
+ * `HostPlatformId` `resolveSelectedHostPlatforms` returns. Deliberately does
2020
+ * NOT project `MCP_HOST_TARGETS[id].label` — that registry label reads
2021
+ * "OpenAI Codex", which is not this feature's approved wording.
2022
+ */
2023
+ const OPTIMIZATION_POSTURE_SUBSCRIPTION_NAMES = {
2024
+ "claude-code": "Claude Code",
2025
+ codex: "Codex",
2026
+ cursor: "Cursor",
2027
+ };
2028
+ /**
2029
+ * `"{name} subscription"` for the three eligible platforms, and the generic
2030
+ * `"coding subscription"` for every other input — Copilot, Windsurf, an
2031
+ * unrecognized id, or no primary platform at all (empty tool selection).
2032
+ */
2033
+ export function optimizationPostureSubscriptionNounPhrase(primaryPlatform) {
2034
+ const mapped = primaryPlatform ? OPTIMIZATION_POSTURE_SUBSCRIPTION_NAMES[primaryPlatform] : undefined;
2035
+ return mapped ? `${mapped} subscription` : "coding subscription";
2036
+ }
2037
+ /** Option three's exact wording, platform-aware (BAPI-1221 Step 2). */
2038
+ function buildOptimizationPostureOptionThree(primaryPlatform) {
2039
+ return `Get the most out of your ${optimizationPostureSubscriptionNounPhrase(primaryPlatform)}?`;
2040
+ }
2041
+ /** The neutral single-line correction reprompted for any malformed answer. */
2042
+ const OPTIMIZATION_POSTURE_CORRECTION = "Enter a single number from 1 to 3.";
2043
+ /**
2044
+ * Bounds the picker's correction reprompts, mirroring this file's existing
2045
+ * {@link INSTALL_BRIDGE_MAX_CODE_REJECTIONS} convention. A real interactive
2046
+ * user always reaches a terminal answer well inside this many tries — Enter
2047
+ * (blank) or Ctrl+D (EOF) both resolve immediately — so this cap exists only
2048
+ * to stop a `promptLine` seam that NEVER produces a digit or a blank answer
2049
+ * from reprompting forever. Set comfortably above the widest malformed-input
2050
+ * sweep this picker is tested against (a comma list, a range, signed and
2051
+ * decimal values, `0`, `4`, blank-with-more-input, and arbitrary text).
2052
+ */
2053
+ const OPTIMIZATION_POSTURE_MAX_CORRECTIONS = 20;
2054
+ /**
2055
+ * Render the one-time posture menu and its input instruction. Exactly three
2056
+ * vertically ordered numbered options; option three is platform-aware. Plain,
2057
+ * wrap-safe text — no color-only state, icons, or decorative framing.
2058
+ */
2059
+ function buildOptimizationPostureMenu(primaryPlatform) {
2060
+ return ("How should Bridge optimize its work on this repository?\n" +
2061
+ " 1. Quality\n" +
2062
+ " 2. Speed\n" +
2063
+ ` 3. ${buildOptimizationPostureOptionThree(primaryPlatform)}\n` +
2064
+ `${OPTIMIZATION_POSTURE_CORRECTION} `);
2065
+ }
2066
+ /**
2067
+ * The purpose-built, strict single-select optimization-posture picker
2068
+ * (BAPI-1221 Step 2). Renders the three-option menu exactly once and loops
2069
+ * ONLY the neutral correction line on a malformed answer — never
2070
+ * `promptMultiSelectViaReadline()`, whose comma-separated contract this
2071
+ * picker deliberately does not share.
2072
+ *
2073
+ * Accepts only a trimmed single digit `1`, `2`, or `3`; every other answer
2074
+ * (a comma list, a range, a signed or decimal value, blank text, or
2075
+ * unrelated text) is malformed and reprompted. EOF, an unavailable
2076
+ * `deps.promptLine` seam, or a genuinely blank/no-answer result all resolve
2077
+ * `null` rather than looping indefinitely — `promptLineViaReadline` itself
2078
+ * already collapses real EOF and an empty typed answer to the same `""`, so
2079
+ * there is no seam-level way (or need) to tell them apart here.
2080
+ */
2081
+ export async function promptOptimizationPosture(deps, primaryPlatform) {
2082
+ if (!deps.isTTY || !deps.promptLine)
2083
+ return null;
2084
+ const promptLine = deps.promptLine;
2085
+ try {
2086
+ let answer = (await promptLine(buildOptimizationPostureMenu(primaryPlatform))).trim();
2087
+ for (let attempts = 0;; attempts++) {
2088
+ if (answer === "1")
2089
+ return "quality";
2090
+ if (answer === "2")
2091
+ return "speed";
2092
+ if (answer === "3")
2093
+ return "maximize_subscription";
2094
+ if (answer === "")
2095
+ return null;
2096
+ if (attempts >= OPTIMIZATION_POSTURE_MAX_CORRECTIONS)
2097
+ return null;
2098
+ answer = (await promptLine(`${OPTIMIZATION_POSTURE_CORRECTION} `)).trim();
2099
+ }
2100
+ }
2101
+ catch {
2102
+ // Prompt/stream exception at the posture-step boundary: fail open to a
2103
+ // no-answer outcome rather than aborting an otherwise-complete install.
2104
+ return null;
2105
+ }
2106
+ }
2107
+ /** Build the posture config-field URL, matching {@link buildPingUrl}'s normalization. */
2108
+ function buildOptimizationPostureUrl(baseUrl) {
2109
+ return `${baseUrl.replace(/\/+$/, "")}/jira/config-field/optimization_posture`;
2110
+ }
2111
+ /**
2112
+ * Persist the captured posture through the existing, generic configuration
2113
+ * endpoint (BAPI-1221 Step 4) — the SAME `PUT /jira/config-field/{field_name}`
2114
+ * every other config-field write uses; this ticket adds no new endpoint,
2115
+ * model, or migration. Fail-open by design: a missing repo identity, a
2116
+ * forbidden write, any other non-OK response, or a network/timeout failure
2117
+ * all return a coarse outcome rather than throwing, and none of them retries
2118
+ * or blocks the rest of installation. The response body is never read or
2119
+ * logged, and the API key never appears in the URL, body, or any returned
2120
+ * message.
2121
+ */
2122
+ export async function persistOptimizationPosture(deps, baseUrl, apiKey, repoName, value) {
2123
+ if (!repoName || repoName.trim().length === 0) {
2124
+ return { kind: "missing-repo-identity" };
2125
+ }
2126
+ let resp;
2127
+ try {
2128
+ resp = await deps.fetch(buildOptimizationPostureUrl(baseUrl), {
2129
+ method: "PUT",
2130
+ headers: { "X-API-Key": apiKey, "Content-Type": "application/json" },
2131
+ body: JSON.stringify({ repo_name: repoName, value }),
2132
+ signal: AbortSignal.timeout(10_000),
2133
+ });
2134
+ }
2135
+ catch {
2136
+ return { kind: "request-failed" };
2137
+ }
2138
+ if (resp.ok)
2139
+ return { kind: "success" };
2140
+ if (resp.status === 403)
2141
+ return { kind: "forbidden" };
2142
+ return { kind: "http-error", status: resp.status };
2143
+ }
2144
+ /** Confirmation noun phrase used in the deferred-activation success message. */
2145
+ function optimizationPostureConfirmationPhrase(value, primaryPlatform) {
2146
+ if (value === "quality")
2147
+ return "quality";
2148
+ if (value === "speed")
2149
+ return "speed";
2150
+ return `getting the most out of your ${optimizationPostureSubscriptionNounPhrase(primaryPlatform)}`;
2151
+ }
2152
+ /** Printed immediately after a successful posture write, on its own line. */
2153
+ const OPTIMIZATION_POSTURE_BLOG_URL = "https://bridgegpt.ai/blog/how-we-optimize-your-setup";
2154
+ const OPTIMIZATION_POSTURE_REMEDIATION = "Update it later with /teach-bridge or the optimization_posture setting in the web UI.";
2155
+ /**
2156
+ * The generic, secret-free fail-open message shared by every non-success,
2157
+ * non-forbidden outcome — an interactive no-answer/prompt-exception (no PUT
2158
+ * was ever attempted) and a missing-repo-identity/HTTP-error/network failure
2159
+ * (a PUT was attempted and did not take). States plainly that the current
2160
+ * server value is unchanged; a new repository merely READS as Quality via
2161
+ * the BAPI-1220 server-side default, so this never claims every repository
2162
+ * IS now Quality.
2163
+ */
2164
+ const OPTIMIZATION_POSTURE_UNCHANGED_MESSAGE = ` Optimization posture was not changed; new repositories use Quality by default. ${OPTIMIZATION_POSTURE_REMEDIATION}`;
2165
+ /**
2166
+ * Report the outcome of the optional posture capture/persistence step
2167
+ * (BAPI-1221 Step 5) — called only when an explicit interactive selection was
2168
+ * captured and the PUT helper actually ran.
2169
+ */
2170
+ function reportOptimizationPostureOutcome(log, result, value, primaryPlatform) {
2171
+ if (result.kind === "success") {
2172
+ log(` Preference saved for later activation — we'll optimize for ${optimizationPostureConfirmationPhrase(value, primaryPlatform)}.`);
2173
+ log(` ${OPTIMIZATION_POSTURE_BLOG_URL}`);
2174
+ return;
2175
+ }
2176
+ if (result.kind === "forbidden") {
2177
+ log(` Optimization posture was not changed (an admin API key is required). ${OPTIMIZATION_POSTURE_REMEDIATION}`);
2178
+ return;
2179
+ }
2180
+ // missing-repo-identity, http-error, request-failed: none carries
2181
+ // user-actionable detail beyond "it didn't take."
2182
+ log(OPTIMIZATION_POSTURE_UNCHANGED_MESSAGE);
2183
+ }
2015
2184
  /**
2016
2185
  * Resolve the selected host platforms with strict precedence (BAPI-635):
2017
2186
  * 1. explicit `--tools` (including an explicit EMPTY selection — never falls
@@ -4947,14 +5116,26 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
4947
5116
  // global (Codex) or manual (Windsurf) target is provisioned by the
4948
5117
  // registry-driven emitter after the connectivity check.
4949
5118
  const selectedPlatforms = await resolveSelectedHostPlatforms(deps, options);
5119
+ // ---- BAPI-1221 (S2): capture the optimization-posture preference ----
5120
+ // Asked immediately after tool selection, on every otherwise-eligible
5121
+ // interactive install (including reinstalls and an explicit empty
5122
+ // selection) — a prior posture is never read first. `--dry-run` already
5123
+ // returns long before this point (see the NOTE below), so this gate is
5124
+ // belt-and-suspenders, not the sole guard. The primary platform is
5125
+ // `selectedPlatforms[0]` exclusively, relying on the resolver's own
5126
+ // `HOST_PLATFORM_ORDER` normalization even when several tools were chosen.
5127
+ const optimizationPostureEligible = deps.isTTY && Boolean(deps.promptLine) && !options.dryRun;
5128
+ const optimizationPostureSelection = optimizationPostureEligible
5129
+ ? await promptOptimizationPosture(deps, selectedPlatforms[0])
5130
+ : null;
4950
5131
  if (options.codexProfile && !selectedPlatforms.includes("codex")) {
4951
5132
  errorLog("Error: --codex-profile requires Codex in --tools or the interactive selection.");
4952
5133
  return 1;
4953
5134
  }
4954
5135
  let codexProfileChoice = options.codexProfile;
4955
5136
  if (selectedPlatforms.includes("codex") && !options.dryRun && !codexProfileChoice && deps.isTTY && deps.promptLine) {
4956
- const answer = (await deps.promptLine("Install the opt-in Bridge Codex approval profile for spawned workers? (y/N) ")).trim().toLowerCase();
4957
- codexProfileChoice = answer === "y" || answer === "yes" ? "on" : "off";
5137
+ const answer = (await deps.promptLine("Install the opt-in Bridge Codex approval profile for spawned workers? (Y/n) ")).trim().toLowerCase();
5138
+ codexProfileChoice = answer === "" || answer === "y" || answer === "yes" ? "on" : "off";
4958
5139
  }
4959
5140
  const targets = hostConfigTargetsForPlatforms(selectedPlatforms);
4960
5141
  // Read-only detection (safe in dry-run) of global-config editors we can't write.
@@ -5647,6 +5828,18 @@ export async function runInstallBridgeCli(argv, overrides = {}) {
5647
5828
  : `Error: ${ping.message}`);
5648
5829
  }
5649
5830
  log(" connectivity OK");
5831
+ // ---- BAPI-1221 (S2): persist the captured posture, now that connectivity
5832
+ // is validated and `repoName`/`apiKey` are both final. Overwrites any
5833
+ // existing value unconditionally — the old value is never read first. A
5834
+ // no-answer (or a prompt/stream exception the picker already absorbed)
5835
+ // reports the same fail-open remediation without ever attempting a write.
5836
+ if (optimizationPostureSelection) {
5837
+ const postureResult = await persistOptimizationPosture(deps, baseUrl, apiKey, repoName, optimizationPostureSelection);
5838
+ reportOptimizationPostureOutcome(log, postureResult, optimizationPostureSelection, selectedPlatforms[0]);
5839
+ }
5840
+ else if (optimizationPostureEligible) {
5841
+ log(OPTIMIZATION_POSTURE_UNCHANGED_MESSAGE);
5842
+ }
5650
5843
  const entry = buildInstallBridgeServerEntry(deps.cwd, repoName, apiKey, baseUrl, docsDir);
5651
5844
  // BAPI-666: the secret-free variant (no BAPI_API_KEY) is written instead of the
5652
5845
  // real entry into any git-tracked project config the user did not explicitly
@@ -104,7 +104,7 @@ export async function resolveInstallDoctorTarget(deps) {
104
104
  break;
105
105
  }
106
106
  }
107
- return { repoName, repoSource, baseUrl: baseUrl ?? DEFAULT_BASE_URL };
107
+ return { repoName, repoSource, baseUrl: (baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "") };
108
108
  }
109
109
  /** Classify a thrown probe failure into the closed category vocabulary. */
110
110
  function classifyProbeFailure(error) {
@@ -10,6 +10,12 @@ export function adaptBridgeEntryForHostTarget(entry, target) {
10
10
  args: [...entry.args],
11
11
  env: { ...entry.env },
12
12
  };
13
+ // The marker belongs to the installed physical registration, never the
14
+ // portable logical entry or committed .bridge/config. Replacing an entry
15
+ // refreshes it; removing the registration removes it automatically.
16
+ delete physical.env.BAPI_MCP_HOST_FAMILY;
17
+ if (target.routingHostFamily)
18
+ physical.env.BAPI_MCP_HOST_FAMILY = target.routingHostFamily;
13
19
  if (adapter.transportType !== undefined)
14
20
  physical.type = adapter.transportType;
15
21
  if (adapter.extraEntryKeys)
@@ -67,6 +67,7 @@ export function hostAdapterForTarget(target) {
67
67
  export const MCP_HOST_TARGETS = {
68
68
  "claude-code": {
69
69
  id: "claude-code",
70
+ routingHostFamily: "claude_code",
70
71
  label: "Claude Code",
71
72
  scope: "project",
72
73
  relPath: ".mcp.json",
@@ -106,6 +107,7 @@ export const MCP_HOST_TARGETS = {
106
107
  },
107
108
  codex: {
108
109
  id: "codex",
110
+ routingHostFamily: "codex",
109
111
  label: "OpenAI Codex",
110
112
  scope: "global",
111
113
  absPathResolver: (homedir) => joinPath(homedir, ".codex/config.toml"),
@@ -306,21 +306,21 @@ export async function resolveBapiInvocation(projectRoot, deps) {
306
306
  BAPI_PROJECT_ROOT: projectRoot,
307
307
  BAPI_REPO_NAME: repoName,
308
308
  };
309
- // BAPI-527: `CONDUCTOR_NODE_PATH` is NON-SECRET operational metadata (the
309
+ // BAPI-527: `EPIC_IMPLEMENTER_NODE_PATH` is NON-SECRET operational metadata (the
310
310
  // captured conductor Node path used by the worker-ledger CLI subprocess).
311
311
  // `buildChildEnv` already inherits the full parent env, so this re-affirms the
312
312
  // pass-through explicitly and ONLY when present — it is a non-secret allowlist
313
313
  // entry, never a credential. It is emphatically NOT the MCP server's re-exec
314
314
  // command: the spawned command stays `deps.execPath` + `[scriptPath]` below.
315
- const conductorNodePath = deps.env.CONDUCTOR_NODE_PATH;
315
+ const conductorNodePath = deps.env.EPIC_IMPLEMENTER_NODE_PATH;
316
316
  if (typeof conductorNodePath === "string" && conductorNodePath.trim().length > 0) {
317
- overlay.CONDUCTOR_NODE_PATH = conductorNodePath;
317
+ overlay.EPIC_IMPLEMENTER_NODE_PATH = conductorNodePath;
318
318
  }
319
319
  // BAPI-727: overlay the endpoint ONLY when `--base-url` was supplied, mirroring
320
- // the conditional `CONDUCTOR_NODE_PATH` overlay directly above. The absent case
320
+ // the conditional `EPIC_IMPLEMENTER_NODE_PATH` overlay directly above. The absent case
321
321
  // must add, delete, normalize, and override nothing — `buildChildEnv` already
322
322
  // inherits the parent's `BAPI_BASE_URL`, and the server keeps its own production
323
- // default for callers that were never given a URL. Like `CONDUCTOR_NODE_PATH`,
323
+ // default for callers that were never given a URL. Like `EPIC_IMPLEMENTER_NODE_PATH`,
324
324
  // this is non-secret routing metadata, not a credential.
325
325
  if (typeof deps.baseUrl === "string" && deps.baseUrl.length > 0) {
326
326
  overlay.BAPI_BASE_URL = deps.baseUrl;
@@ -15,7 +15,7 @@
15
15
  */
16
16
  const VALID_GROUPS = new Set([
17
17
  "core",
18
- "conductor",
18
+ "epic_implementer",
19
19
  "estimation",
20
20
  "pipeline-authoring",
21
21
  "sfcc",
@@ -49,7 +49,7 @@ export function resolveProfiles(raw) {
49
49
  // rather than an implicit widening of an existing user's surface.
50
50
  return new Set([
51
51
  "core",
52
- "conductor",
52
+ "epic_implementer",
53
53
  "estimation",
54
54
  "pipeline-authoring",
55
55
  "sfcc",
@@ -67,7 +67,7 @@ export function serverNameForMcpTarget(target) {
67
67
  * node executable is a single substitutable field), and the npm-channel fallback
68
68
  * as `{ command: "npx", args: ["-y", "<spec>", "mcp-invoke", …] }`.
69
69
  *
70
- * BAPI-527: `.mcp.json` remains intentionally ENV-FREE. `CONDUCTOR_NODE_PATH`
70
+ * BAPI-527: `.mcp.json` remains intentionally ENV-FREE. `EPIC_IMPLEMENTER_NODE_PATH`
71
71
  * (the captured conductor Node path for the worker-ledger CLI subprocess) is NOT
72
72
  * written here — it is inherited at runtime from the worker SHELL env that
73
73
  * `start-tickets` injects at the spawn boundary, never stored as a registration
@@ -77,7 +77,7 @@ export function serverNameForMcpTarget(target) {
77
77
  *
78
78
  * BAPI-724: the executor caller's `invocation` is resolved BEFORE spawn from its
79
79
  * OWN running process (`resolveMcpShimInvocationForRuntime`, absolute-build-path
80
- * form preferred) and never depends on a worker-injected `CONDUCTOR_NODE_PATH`
80
+ * form preferred) and never depends on a worker-injected `EPIC_IMPLEMENTER_NODE_PATH`
81
81
  * or any other worker-shell env — the executor worker environment carries no
82
82
  * such key at all (see `executor/env.ts`).
83
83
  *
@@ -17,7 +17,7 @@
17
17
  * server file is invoked (`node <absPath>/index.js` vs `npx @…`). It does NOT
18
18
  * change WHICH Node binary runs that file — the `nodeExecutable` field is kept as
19
19
  * a single substitutable value (never string-concatenated into the command) so
20
- * the later Ticket 3 (P2) work can point it at `CONDUCTOR_NODE_PATH` without
20
+ * the later Ticket 3 (P2) work can point it at `EPIC_IMPLEMENTER_NODE_PATH` without
21
21
  * re-plumbing argv construction.
22
22
  */
23
23
  import path from "node:path";
@@ -48,7 +48,7 @@ export function isTransientNpxEntryPath(entryPath) {
48
48
  * Build the concrete `{ command, args }` launch pair for a target + worktree from
49
49
  * a structured invocation. Pure — no filesystem or process access. The
50
50
  * `nodeExecutable` is returned as the standalone `command`, never concatenated
51
- * with the server entry path, so `CONDUCTOR_NODE_PATH` can substitute it later.
51
+ * with the server entry path, so `EPIC_IMPLEMENTER_NODE_PATH` can substitute it later.
52
52
  */
53
53
  export function buildMcpShimCommand(invocation, target, absoluteWorktreePath) {
54
54
  if (invocation.form === "absolute-build-path") {
@@ -141,7 +141,7 @@ function basenameAnySep(filePath) {
141
141
  * NOTE (scope boundary): this ticket changes WHICH server file is invoked, not
142
142
  * WHICH Node runtime runs it. `nodeExecutable` defaults to `"node"` (NOT
143
143
  * `process.execPath`) to preserve today's worker runtime selection; Ticket 3 (P2)
144
- * can later inject `CONDUCTOR_NODE_PATH` into this single field.
144
+ * can later inject `EPIC_IMPLEMENTER_NODE_PATH` into this single field.
145
145
  */
146
146
  export function resolveMcpShimInvocationForRuntime(deps) {
147
147
  const nodeExecutable = deps.nodeExecutable ?? "node";
@@ -102,6 +102,19 @@ const CI_NOT_GREEN_REASON = "ci_not_green";
102
102
  * moves an unreadable, unsupported, or malformed review configuration.
103
103
  */
104
104
  const REVIEW_NOT_APPROVED_REASON = "review_not_approved";
105
+ /**
106
+ * BAPI-1234 — the server's migration-graph merge guard. Both are named here so
107
+ * the mapping is explicit rather than an accident of the default branch, and
108
+ * both are `needs_human`: repeating the call against the same base and head
109
+ * reproduces the same refusal. `migration_graph_invalid` means a human must
110
+ * renumber the PR's own migration; `migration_graph_unreadable` means the graph
111
+ * could not be proven either way. The exact reason is returned unchanged so the
112
+ * caller can tell them apart; no server-supplied text is added.
113
+ */
114
+ const MIGRATION_GRAPH_REFUSAL_REASONS = new Set([
115
+ "migration_graph_invalid",
116
+ "migration_graph_unreadable",
117
+ ]);
105
118
  // ---------------------------------------------------------------------------
106
119
  // Envelope construction
107
120
  // ---------------------------------------------------------------------------
@@ -560,6 +573,8 @@ function retryHintForFailure(reason, ciSummary) {
560
573
  }
561
574
  if (reason === REVIEW_NOT_APPROVED_REASON)
562
575
  return "retry_later";
576
+ if (MIGRATION_GRAPH_REFUSAL_REASONS.has(reason))
577
+ return "needs_human";
563
578
  return "needs_human";
564
579
  }
565
580
  /**
@@ -0,0 +1,25 @@
1
+ const CLIENT_NAMES = {
2
+ // `claude-code` is observed in the handshake captured in learn-tool-gating.ts.
3
+ "claude-code": "claude_code",
4
+ "codex": "codex",
5
+ };
6
+ export function resolveRoutingHost(clientName, marker) {
7
+ const fromClient = typeof clientName === "string" && Object.hasOwn(CLIENT_NAMES, clientName)
8
+ ? CLIENT_NAMES[clientName] : null;
9
+ const fromMarker = marker === "codex" || marker === "claude_code" ? marker : null;
10
+ if (marker != null && !fromMarker)
11
+ return { family: fromClient, source: fromClient ? "initialized_client" : "neutral", warning: "invalid_marker" };
12
+ if (fromClient && fromMarker && fromClient !== fromMarker)
13
+ return {
14
+ family: null, source: "neutral", warning: "marker_conflict",
15
+ };
16
+ if (fromClient)
17
+ return { family: fromClient, source: "initialized_client" };
18
+ if (fromMarker)
19
+ return { family: fromMarker, source: "installer_marker" };
20
+ return { family: null, source: "neutral" };
21
+ }
22
+ export function resolveInitializedRoutingHost(server, marker = process.env.BAPI_MCP_HOST_FAMILY) {
23
+ // Called from a registered handler, after MCP initialization.
24
+ return resolveRoutingHost(server.server.getClientVersion?.()?.name, marker);
25
+ }