@bridge_gpt/mcp-server 0.2.24 → 0.2.26

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 (36) hide show
  1. package/README.md +98 -28
  2. package/build/agents.generated.js +1 -1
  3. package/build/bridge-api-urls.js +31 -0
  4. package/build/commands.generated.js +5 -5
  5. package/build/conductor/epic-reconcile.js +7 -1
  6. package/build/conductor/epic-runtime.js +5 -0
  7. package/build/conductor-bundle-artifacts.js +802 -0
  8. package/build/conductor-bundle-cli.js +256 -0
  9. package/build/connect-github-api.js +365 -0
  10. package/build/connect-github.js +415 -0
  11. package/build/decision-page-schema.js +34 -5
  12. package/build/decision-page-template.js +117 -35
  13. package/build/docs.generated.js +2 -1
  14. package/build/doctor.js +148 -1
  15. package/build/env-flags.js +31 -0
  16. package/build/index.js +3467 -498
  17. package/build/init.js +7 -3
  18. package/build/install-bridge.js +624 -38
  19. package/build/install-doctor.js +64 -0
  20. package/build/mcp-host-config.js +521 -0
  21. package/build/mcp-host-targets.js +194 -0
  22. package/build/mcp-install-state.js +175 -0
  23. package/build/pipelines.generated.js +127 -132
  24. package/build/readme.generated.js +1 -1
  25. package/build/start-tickets.js +166 -18
  26. package/build/tool-surface-gating.js +396 -0
  27. package/build/version.generated.js +1 -1
  28. package/docs/install/github-app.md +80 -17
  29. package/docs/install/mcp-tool-integrations.md +2 -2
  30. package/package.json +5 -5
  31. package/pipelines/learn-repository.json +111 -119
  32. package/public/css/main.min.css +258 -65
  33. package/public/css/main.min.css.map +1 -1
  34. package/public/js/main.min.js +188 -92
  35. package/public/js/main.min.js.map +1 -1
  36. package/smoke-test/SMOKE-TEST.md +4 -4
@@ -87,6 +87,16 @@ export const DEFAULT_MAX_PARALLEL = 3;
87
87
  export const DEFAULT_TMUX_SESSION_PREFIX = "bridge-start-tickets";
88
88
  /** Environment variable overriding the tmux session-name prefix. */
89
89
  export const TMUX_SESSION_OVERRIDE_ENV = "BAPI_TMUX_SESSION";
90
+ /**
91
+ * BAPI-642 — internal marker for an explicitly supplied `--tier` value that is
92
+ * NOT one of the three recognized {@link ModelTier} names (a malformed or empty
93
+ * flag). It is deliberately distinct from an OMITTED flag (the option property is
94
+ * absent entirely in that case): an unresolved marker records that the operator
95
+ * asked for a coarse override we could not honor, so the routing layer fails open
96
+ * to the premium fallback rather than silently entering difficulty-based routing.
97
+ * The sentinel string is never a valid `ModelTier`, so `isModelTier` rejects it.
98
+ */
99
+ export const INJECTED_TIER_UNRESOLVED = "__unresolved__";
90
100
  /** Return a copy of `row` with `warning` appended; never mutates the input. */
91
101
  export function appendSummaryRowWarning(row, warning) {
92
102
  return { ...row, warnings: [...(row.warnings ?? []), warning] };
@@ -103,6 +113,7 @@ export function getStartTicketsUsage() {
103
113
  "Flags:",
104
114
  " --agent claude|cursor-agent Agent command to launch in each worktree (default: claude)",
105
115
  " --workflow implement|review-and-implement Slash command each spawned worktree runs (default: implement). review-and-implement runs /review-ticket then, after a per-ticket halt gate, /implement-ticket in the same session; --auto applies to the selected workflow.",
116
+ " --tier cheap|basic|premium Coarse model-routing override: bypasses the per-ticket difficulty/tier lookup and applies this tier to every ticket. It is still mapped to a model through the agent registry and any configured difficulty_model_tier_overrides, then validated — it is NOT a raw --model alias, and never carries an API key or credential. A malformed value fails open to premium routing.",
106
117
  " --rounds 1|2 Review round count forwarded to the review phase; review-only, valid only with --workflow review-and-implement",
107
118
  " --terminal terminal|iterm Override the macOS terminal app (default: auto-detect via $TERM_PROGRAM); honored on macOS only",
108
119
  " --dry-run Print intended actions; creates no worktrees and opens no tabs, but DOES resolve model routing read-only (may compute+cache a ticket's difficulty) to preview the --model each tab would use",
@@ -156,6 +167,10 @@ export function parseStartTicketsArgs(argv) {
156
167
  let conductorEnabled = false;
157
168
  let workflow = "implement";
158
169
  let reviewRoundsRaw;
170
+ // BAPI-642 `--tier` override. `undefined` = flag absent (property omitted from
171
+ // the parsed options); a `ModelTier` = usable override; INJECTED_TIER_UNRESOLVED
172
+ // = supplied-but-malformed (fail open to premium). NEVER an error/throw.
173
+ let injectedTier;
159
174
  const branchEntries = [];
160
175
  const keys = [];
161
176
  for (let i = 0; i < argv.length; i++) {
@@ -231,6 +246,26 @@ export function parseStartTicketsArgs(argv) {
231
246
  reviewRoundsRaw = value;
232
247
  continue;
233
248
  }
249
+ if (arg === "--tier" || arg.startsWith("--tier=")) {
250
+ // BAPI-642: fail-open coarse routing override. A malformed/empty/missing
251
+ // value is NOT a parse error — it records INJECTED_TIER_UNRESOLVED so the
252
+ // routing layer can fall back to premium instead of aborting the launch.
253
+ let value;
254
+ if (arg.startsWith("--tier=")) {
255
+ value = arg.slice("--tier=".length);
256
+ }
257
+ else {
258
+ // For the space-separated form, refuse to swallow a following option or
259
+ // ticket-key token as the value (leaving it to be parsed normally);
260
+ // an absent value fails open to the unresolved marker.
261
+ const next = i + 1 < argv.length ? argv[i + 1] : undefined;
262
+ if (next !== undefined && !next.startsWith("-") && !TICKET_KEY_PATTERN.test(next)) {
263
+ value = takeValue();
264
+ }
265
+ }
266
+ injectedTier = isModelTier(value) ? value : INJECTED_TIER_UNRESOLVED;
267
+ continue;
268
+ }
234
269
  if (arg === "--terminal" || arg.startsWith("--terminal=")) {
235
270
  let value;
236
271
  if (arg.startsWith("--terminal=")) {
@@ -412,6 +447,10 @@ export function parseStartTicketsArgs(argv) {
412
447
  conductorEnabled,
413
448
  workflow,
414
449
  reviewRounds,
450
+ // BAPI-642: include the injected-tier property ONLY when `--tier` was
451
+ // supplied, so an omitted flag leaves the legacy option shape untouched
452
+ // (no own `injectedTier` key, not even `undefined`).
453
+ ...(injectedTier !== undefined ? { injectedTier } : {}),
415
454
  },
416
455
  };
417
456
  }
@@ -1469,9 +1508,10 @@ const defaultPruneStaleLaunchScriptsDeps = {
1469
1508
  * recursively. Newer entries and unrelated files/dirs are left untouched.
1470
1509
  *
1471
1510
  * Fully fail-open: any error (missing parent dir, unreadable entry, stat/unlink
1472
- * failure) is swallowed and never blocks or aborts a spawn the same fail-open
1473
- * discipline as the launch-script writer fallback in
1474
- * {@link materializeWorkerLaunchCommand}.
1511
+ * failure) is swallowed and never blocks or aborts a spawn. (Unconditionally so,
1512
+ * unlike {@link materializeWorkerLaunchCommand}, whose inline fallback is gated on
1513
+ * {@link MAX_TERMINAL_COMMAND_BYTES} — pruning a stale temp dir has no launchable/
1514
+ * unlaunchable axis to gate on.)
1475
1515
  */
1476
1516
  export async function pruneStaleLaunchScripts(deps = defaultPruneStaleLaunchScriptsDeps) {
1477
1517
  try {
@@ -1523,27 +1563,54 @@ export const defaultWriteWorkerLaunchScript = async ({ platform, key, content, }
1523
1563
  await writeFile(file, content, { mode: 0o600 });
1524
1564
  return file;
1525
1565
  };
1566
+ /**
1567
+ * Canonical maximum UTF-8 byte length of a single command line handed to a
1568
+ * terminal spawner. macOS `osascript`-driven Terminal/iTerm keystroke delivery
1569
+ * silently truncates (or mangles) a longer line, so a command at or below this
1570
+ * bound is "known-launchable" and anything above it is "known-unlaunchable".
1571
+ *
1572
+ * Exported so the launcher and every install-flow guard read the SAME number:
1573
+ * two independently-written literals would silently drift apart, and the whole
1574
+ * point of the bound is that the launcher's fallback decision and the caller's
1575
+ * final pre-spawn check agree.
1576
+ */
1577
+ export const MAX_TERMINAL_COMMAND_BYTES = 1024;
1526
1578
  /**
1527
1579
  * Resolve the command actually handed to the terminal spawner for one worker.
1528
1580
  * When `deps.writeWorkerLaunchScript` is provided, the full command is persisted
1529
- * to a script and a short `source <path>` runner is returned; if writing fails
1530
- * for any reason the original inline command is returned (fail-open — a launch
1531
- * never aborts because a temp file could not be written). When the seam is
1581
+ * to a script and a short `source <path>` runner is returned. When the seam is
1532
1582
  * absent, the inline command is returned unchanged (legacy behaviour).
1583
+ *
1584
+ * The fail-open on a failed write is CONDITIONAL, and the condition is the point.
1585
+ * Falling back to the inline command is only a fallback if the terminal can
1586
+ * actually run it: above {@link MAX_TERMINAL_COMMAND_BYTES} the spawner truncates
1587
+ * the line, so "fail-open" would deliver a corrupted command — a silent, confusing
1588
+ * failure strictly worse than a loud one. So a failed write falls back inline only
1589
+ * at or below the bound, and returns a structured failure above it.
1533
1590
  */
1534
1591
  export async function materializeWorkerLaunchCommand(deps, key, fullCommand) {
1535
1592
  if (!deps.writeWorkerLaunchScript)
1536
- return fullCommand;
1593
+ return { ok: true, command: fullCommand };
1537
1594
  try {
1538
1595
  const scriptPath = await deps.writeWorkerLaunchScript({
1539
1596
  platform: deps.platform,
1540
1597
  key,
1541
1598
  content: buildLaunchScriptContent(deps.platform, fullCommand),
1542
1599
  });
1543
- return buildLaunchScriptRunnerCommand(deps.platform, scriptPath);
1600
+ return { ok: true, command: buildLaunchScriptRunnerCommand(deps.platform, scriptPath) };
1544
1601
  }
1545
1602
  catch {
1546
- return fullCommand;
1603
+ // Bytes, not `.length`: the bound is a byte bound, and a multibyte prompt can
1604
+ // sit under 1024 JS characters while being well over 1024 UTF-8 bytes.
1605
+ if (Buffer.byteLength(fullCommand, "utf8") <= MAX_TERMINAL_COMMAND_BYTES) {
1606
+ return { ok: true, command: fullCommand };
1607
+ }
1608
+ return {
1609
+ ok: false,
1610
+ reason: "launch-script-write-failed-oversized-command",
1611
+ error: "Could not write the temporary launch script, and the full command is too long to send " +
1612
+ "to the terminal directly. Check that the system temporary directory is writable.",
1613
+ };
1547
1614
  }
1548
1615
  }
1549
1616
  // ---------------------------------------------------------------------------
@@ -1569,10 +1636,17 @@ export async function spawnTabsForCreatedWorktrees(deps, rows, terminal, buildSh
1569
1636
  // agents, or conductor disabled). Never mutates process/global env.
1570
1637
  const shellCommand = injectConductorEnvIntoShellCommand(deps.platform, baseShellCommand, row.conductorEnv);
1571
1638
  // Deliver the (potentially multi-KB) command via a launch-script file so the
1572
- // terminal spawn payload stays tiny and escaping-immune. Fail-open / no-op
1573
- // when no writer seam is configured (see materializeWorkerLaunchCommand).
1574
- const runnableCommand = await materializeWorkerLaunchCommand(deps, row.key, shellCommand);
1575
- const result = await deps.spawnTerminalTab(deps, terminal, runnableCommand, {
1639
+ // terminal spawn payload stays tiny and escaping-immune. No-op when no writer
1640
+ // seam is configured; a failed write falls back inline only while the command
1641
+ // is still launchable (see materializeWorkerLaunchCommand).
1642
+ const materialized = await materializeWorkerLaunchCommand(deps, row.key, shellCommand);
1643
+ if (!materialized.ok) {
1644
+ // Known-unlaunchable: spawning the oversized inline command would deliver a
1645
+ // truncated line. Fail this row only — siblings still launch.
1646
+ out.push({ ...row, status: "spawn-failed", error: materialized.error });
1647
+ continue;
1648
+ }
1649
+ const result = await deps.spawnTerminalTab(deps, terminal, materialized.command, {
1576
1650
  key: row.key,
1577
1651
  worktreePath: row.path,
1578
1652
  });
@@ -2251,6 +2325,45 @@ async function applyPremiumFallbackToEligibleRows(deps, rows, isEligible, agent,
2251
2325
  const isCreatedRoutingEligible = (r) => r.status === "created" && !!r.path;
2252
2326
  /** Dry-run path: rows have no worktree path yet but are eligible for a preview. */
2253
2327
  const isDryRunRoutingEligible = (r) => r.status === "dry-run";
2328
+ /**
2329
+ * BAPI-642 — apply a single explicitly injected tier to every eligible row,
2330
+ * bypassing the per-ticket difficulty/tier HTTP lookup. The tier is still mapped
2331
+ * to a concrete alias through the agent registry + per-repo overrides and, for
2332
+ * cursor-agent, live-validated before injection. Rows are stamped with
2333
+ * `difficulty: null`, the supplied `modelTier`, and `modelRoutingSource:
2334
+ * "injected"`. Fail-open: an unmappable tier or a failed validation degrades to
2335
+ * the agent default (with a row warning), never a throw. The alias is resolved +
2336
+ * validated ONCE (identical for every row) so a cursor-agent batch fires at most
2337
+ * one advertised-model probe.
2338
+ */
2339
+ async function applyInjectedTierRoutingToEligibleRows(deps, rows, isEligible, agent, overrides, tier) {
2340
+ const stampInjected = (row) => ({
2341
+ ...row,
2342
+ difficulty: null,
2343
+ modelTier: tier,
2344
+ modelRoutingSource: "injected",
2345
+ });
2346
+ const alias = resolveModelAlias(agent, tier, overrides);
2347
+ if (!alias) {
2348
+ const warning = `model routing: no valid alias for injected tier=${tier}; using agent default`;
2349
+ return rows.map((r) => isEligible(r)
2350
+ ? appendSummaryRowWarning({ ...stampInjected(r), modelAlias: null, modelRoutingReason: warning }, warning)
2351
+ : r);
2352
+ }
2353
+ const validation = await validateResolvedModelAliasForAgent(deps, agent, alias);
2354
+ if (!validation.ok) {
2355
+ return rows.map((r) => isEligible(r)
2356
+ ? appendSummaryRowWarning({ ...stampInjected(r), modelAlias: null, modelRoutingReason: validation.warning }, validation.warning)
2357
+ : r);
2358
+ }
2359
+ return rows.map((r) => isEligible(r)
2360
+ ? {
2361
+ ...stampInjected(r),
2362
+ modelAlias: validation.alias,
2363
+ modelRoutingReason: `injected tier=${tier} model=${validation.alias}`,
2364
+ }
2365
+ : r);
2366
+ }
2254
2367
  /**
2255
2368
  * Shared fail-open routing resolution. `isEligible` selects which rows get a
2256
2369
  * resolved tier/alias; every failure mode (unsupported agent, credential/config/
@@ -2283,6 +2396,21 @@ async function resolveModelRoutingForEligible(deps, rows, options, agent, isElig
2283
2396
  const reason = "model routing: disabled for this repo; using agent default";
2284
2397
  return rows.map((r) => (isEligible(r) ? applyDefaultModelRoutingMetadata(r, reason) : r));
2285
2398
  }
2399
+ // BAPI-642: an explicitly injected `--tier` short-circuits the per-ticket
2400
+ // difficulty/tier lookup — `fetchTicketModelTiersForRows` is NEVER called for
2401
+ // any injected value. A recognized tier is applied to every eligible row; a
2402
+ // malformed value (INJECTED_TIER_UNRESOLVED) fails open to the premium
2403
+ // fallback. When the flag is absent (`injected === undefined`) the legacy
2404
+ // difficulty-lookup path below runs unchanged.
2405
+ const injected = options.injectedTier;
2406
+ if (injected === INJECTED_TIER_UNRESOLVED) {
2407
+ const warning = "model routing: --tier was not one of cheap|basic|premium; defaulting to premium routing";
2408
+ const apply = await makePremiumFallbackApplier(deps, agent, overrides);
2409
+ return rows.map((r) => (isEligible(r) ? apply(r, warning) : r));
2410
+ }
2411
+ if (injected) {
2412
+ return applyInjectedTierRoutingToEligibleRows(deps, rows, isEligible, agent, overrides, injected);
2413
+ }
2286
2414
  const tierMap = await fetchTicketModelTiersForRows(access, eligible, options.maxParallel, isEligible);
2287
2415
  // Lazily resolve+validate the premium fallback alias at most once for this run
2288
2416
  // (the alias is identical across rows; cursor-agent validation spawns a probe).
@@ -2379,12 +2507,25 @@ export async function resolveModelRoutingForDryRun(deps, rows, options, agent) {
2379
2507
  /**
2380
2508
  * Format one concise routing decision line per ticket:
2381
2509
  * `KEY difficulty=<n|?> tier=<tier|fallback> agent=<name> model=<alias|default>`.
2382
- */
2383
- export function formatModelRoutingLine(row, agent) {
2510
+ *
2511
+ * BAPI-642: an optional trailing label distinguishes the two non-default launch
2512
+ * shapes so an operator can tell them apart in the diagnostics — `phase=review`
2513
+ * for a `review-and-implement` chain launch and `routing=injected` for a
2514
+ * `--tier`-driven implementation handoff. The ORDINARY implementation launch (a
2515
+ * plain `--workflow implement` with no `--tier`) appends NO label, so its line
2516
+ * stays byte-for-byte identical to the pre-feature format.
2517
+ */
2518
+ export function formatModelRoutingLine(row, agent, context) {
2384
2519
  const difficulty = typeof row.difficulty === "number" ? String(row.difficulty) : "?";
2385
2520
  const tier = row.modelTier ?? "fallback";
2386
2521
  const model = row.modelAlias ?? "default";
2387
- return `${row.key} difficulty=${difficulty} tier=${tier} agent=${agent.name} model=${model}`;
2522
+ const base = `${row.key} difficulty=${difficulty} tier=${tier} agent=${agent.name} model=${model}`;
2523
+ const labels = [];
2524
+ if (context?.workflow === "review-and-implement")
2525
+ labels.push("phase=review");
2526
+ if (row.modelRoutingSource === "injected")
2527
+ labels.push("routing=injected");
2528
+ return labels.length > 0 ? `${base} ${labels.join(" ")}` : base;
2388
2529
  }
2389
2530
  /** Stable de-dup key for an invocation-level routing diagnostic. */
2390
2531
  function modelRoutingDiagnosticKey(d) {
@@ -2718,7 +2859,7 @@ export async function orchestrateStartTickets(deps, options, overrides = {}) {
2718
2859
  for (const row of routed) {
2719
2860
  if (row.status !== "created" || !row.path)
2720
2861
  continue;
2721
- overrides.modelRoutingLog?.(formatModelRoutingLine(row, agent));
2862
+ overrides.modelRoutingLog?.(formatModelRoutingLine(row, agent, { workflow: options.workflow }));
2722
2863
  }
2723
2864
  // Single invocation-level routing diagnostic to stderr (de-duplicated across
2724
2865
  // rows). Fail-open: emission is best-effort and never blocks spawning.
@@ -2776,6 +2917,13 @@ export async function runStartTicketsCli(argv, overrides = {}) {
2776
2917
  errorLog(`Error: Unknown agent: '${options.agentName}'. Valid agents: ${formatValidAgentNames()}.`);
2777
2918
  return 1;
2778
2919
  }
2920
+ // BAPI-642: a malformed `--tier` value is fail-open — emit ONE concise,
2921
+ // secret-free warning and continue (routing applies the premium fallback). It
2922
+ // never aborts the launch or changes the numeric exit code.
2923
+ if (options.injectedTier === INJECTED_TIER_UNRESOLVED) {
2924
+ errorLog("Warning: --tier value was not one of cheap|basic|premium; ignoring the override and " +
2925
+ "using premium model routing for every ticket.");
2926
+ }
2779
2927
  if (options.dryRun) {
2780
2928
  // Resolve the repo identity for the preview so the dry-run command matches
2781
2929
  // what the real spawn injects (see prependRepoNameEnvAssignment). The real
@@ -2799,7 +2947,7 @@ export async function runStartTicketsCli(argv, overrides = {}) {
2799
2947
  for (const line of buildDryRunDetailLines(agent, key, branch, deps.platform, deps.env, options.baseBranch, options.autoApprove, modelAlias, options.conductorEnabled ?? false, dryRunRepoName, dryRunMcpInvocation, options.workflow, options.reviewRounds)) {
2800
2948
  log(line);
2801
2949
  }
2802
- log(`DRY-RUN: model routing: ${formatModelRoutingLine(routedRow ?? { key, branch, status: "dry-run" }, agent)}`);
2950
+ log(`DRY-RUN: model routing: ${formatModelRoutingLine(routedRow ?? { key, branch, status: "dry-run" }, agent, { workflow: options.workflow })}`);
2803
2951
  if (modelAlias == null && routedRow?.modelRoutingReason) {
2804
2952
  log(`DRY-RUN: ${routedRow.modelRoutingReason}`);
2805
2953
  }