@bridge_gpt/mcp-server 0.2.0 → 0.2.2

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.
@@ -40,8 +40,9 @@
40
40
  * via `; exec $SHELL`); attach with `tmux attach -t <session>`.
41
41
  *
42
42
  * Any other `process.platform` value fails fast with a clear "unsupported
43
- * platform" message. `--dry-run` short-circuits before routing so it previews
44
- * the platform-correct command form on any OS.
43
+ * platform" message. `--dry-run` creates no worktrees and opens no tabs, but it
44
+ * DOES resolve model routing read-only (fail-open) so the preview shows the
45
+ * exact platform-correct command form — including the chosen `--model` — on any OS.
45
46
  *
46
47
  * The single highest-risk Windows detail is the `wt` name collision: Windows
47
48
  * Terminal is `wt.exe` (tab launcher) while Worktrunk installs as `git-wt`
@@ -53,16 +54,19 @@
53
54
  * unit-testable on Linux CI without spawning real commands or terminals.
54
55
  */
55
56
  import { execFile } from "child_process";
56
- import { readFile, writeFile, mkdir } from "fs/promises";
57
+ import { readFile, writeFile, mkdir, stat } from "fs/promises";
58
+ import os from "node:os";
57
59
  import path from "path";
58
60
  import { VERSION } from "./version.generated.js";
61
+ import { resolveBapiCredentials } from "./credential-store.js";
62
+ import { readBridgeConfig } from "./bridge-config.js";
59
63
  import { provisionMcpRegistrationsForCreatedWorktrees, } from "./mcp-provisioning.js";
60
64
  // Per-OS prerequisite knowledge + low-level command probes live in the shared
61
65
  // prereqs module so `runPreflight` (enforce) and the read-only `doctor` (render)
62
66
  // can never drift. `start-tickets.ts` imports VALUES from there; the prereqs
63
67
  // module imports only TYPES back, so the runtime graph stays acyclic.
64
68
  import { WORKTRUNK_BINARY_OVERRIDE_ENV, WINDOWS_TERMINAL_COMMAND, WINDOWS_POWERSHELL_CANDIDATES, DEFAULT_WINDOWS_WORKTRUNK_BINARY, DEFAULT_POSIX_WORKTRUNK_BINARY, TMUX_COMMAND, GIT_FOR_WINDOWS_BASH_HINT, isSupportedStartTicketsPlatform, unsupportedPlatformMessage, resolveWorktrunkBinary, commandSucceeded, getCommandProbe, isCommandOnPath, resolveFirstCommandOnPath, enforcePreflightPrerequisites, appendDoctorHint, } from "./start-tickets-prereqs.js";
65
- import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, } from "./agent-registry.js";
69
+ import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, resolveModelAlias, isValidModelAlias, isModelTier, } from "./agent-registry.js";
66
70
  // Re-export the shared prereq surface (constants, platform helpers, command
67
71
  // probes) so existing import sites that read them from "./start-tickets.js"
68
72
  // keep working unchanged.
@@ -94,7 +98,7 @@ export function getStartTicketsUsage() {
94
98
  "Flags:",
95
99
  " --agent claude|cursor-agent Agent command to launch in each worktree (default: claude)",
96
100
  " --terminal terminal|iterm Override the macOS terminal app (default: auto-detect via $TERM_PROGRAM); honored on macOS only",
97
- " --dry-run Print intended actions; create no worktrees, open no tabs",
101
+ " --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",
98
102
  " --branch KEY=BRANCH Use BRANCH instead of feature/KEY for that ticket (repeatable)",
99
103
  " --base-branch BRANCH Cut new worktrees from BRANCH and refresh origin/BRANCH (default: main)",
100
104
  " --no-refresh-main Skip refresh of the configured base branch (default main); historical name retained for backward compatibility",
@@ -391,7 +395,7 @@ export function resolveStartTicketsPlatformConfig(deps, agent, autoApprove = fal
391
395
  config: {
392
396
  platform,
393
397
  worktrunkBinary: resolveWorktrunkBinary(platform, deps.env),
394
- buildAgentShellCommand: (key, worktreePath) => buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove),
398
+ buildAgentShellCommand: (key, worktreePath, modelAlias) => buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove, modelAlias),
395
399
  spawnTerminalTab: deps.spawnTerminalTab,
396
400
  },
397
401
  };
@@ -465,6 +469,8 @@ export function createDefaultStartTicketsDeps() {
465
469
  // Worktrunk JSON / git porcelain output can be large; give it room.
466
470
  maxBuffer: 64 * 1024 * 1024,
467
471
  encoding: "utf-8",
472
+ // Optional bounded timeout (e.g. cursor-agent --list-models probes).
473
+ timeout: options?.timeoutMs,
468
474
  }, (error, stdout, stderr) => {
469
475
  const exitCode = error && typeof error.code === "number"
470
476
  ? (error.code)
@@ -799,44 +805,68 @@ export async function createWorktrees(deps, options, worktrunkBinary) {
799
805
  * (`/implement-ticket <KEY> --auto`) — used by full-automation chains.
800
806
  */
801
807
  export function buildAgentPrompt(key, opts = {}) {
808
+ // `modelAlias` is accepted for signature consistency only — the model is
809
+ // injected as a `--model` flag (see buildAgentInvocationArgv), never embedded
810
+ // in the prompt text.
802
811
  return `/implement-ticket ${key}${opts.autoApprove ? " --auto" : ""}`;
803
812
  }
804
813
  /**
805
- * Build the agent invocation (`<command> <quotedPrompt>`) for the agent's prompt
806
- * style. Only `positional` exists today; the `switch` keeps a future flag-style
807
- * agent a typed extension rather than a silent fall-through. `quote` applies the
808
- * platform-correct quoting to the prompt.
814
+ * Build the ordered argv for an agent invocation:
815
+ * `[command, (--model, alias)?, prompt]`. The model flag+alias are appended ONLY
816
+ * when the agent supports a model override AND `modelAlias` is a non-empty valid
817
+ * alias; otherwise they are omitted entirely (fail-open). The prompt is always
818
+ * the final argument. Pure and never throws on an invalid alias.
809
819
  */
810
- export function buildAgentInvocation(agent, prompt, quote) {
820
+ export function buildAgentInvocationArgv(agent, prompt, modelAlias) {
821
+ const argv = [agent.command];
822
+ if (agent.supportsModelOverride &&
823
+ typeof modelAlias === "string" &&
824
+ isValidModelAlias(modelAlias)) {
825
+ argv.push(agent.modelFlag, modelAlias);
826
+ }
827
+ argv.push(prompt);
828
+ return argv;
829
+ }
830
+ /**
831
+ * Build the agent invocation string for the agent's prompt style, from the
832
+ * validated argv array. The registry-controlled command head stays unquoted
833
+ * (it is never untrusted input); every following argument (the optional
834
+ * `--model <alias>` and the prompt) is run through the platform-correct `quote`.
835
+ */
836
+ export function buildAgentInvocation(agent, prompt, quote, modelAlias) {
811
837
  switch (agent.promptArgStyle) {
812
- case "positional":
813
- return `${agent.command} ${quote(prompt)}`;
838
+ case "positional": {
839
+ const [command, ...rest] = buildAgentInvocationArgv(agent, prompt, modelAlias);
840
+ const quotedRest = rest.map(quote);
841
+ return [command, ...quotedRest].join(" ");
842
+ }
814
843
  default: {
815
844
  const exhaustive = agent.promptArgStyle;
816
845
  throw new Error(`Unsupported agent promptArgStyle: ${String(exhaustive)}`);
817
846
  }
818
847
  }
819
848
  }
820
- /** POSIX agent shell command: `cd '<path>' && <agent> '<prompt>'`. */
821
- export function buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove = false) {
822
- const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove }), (p) => `'${shSquoteInner(p)}'`);
849
+ /** POSIX agent shell command: `cd '<path>' && <agent> [--model '<alias>'] '<prompt>'`. */
850
+ export function buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias) {
851
+ const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove }), (p) => `'${shSquoteInner(p)}'`, modelAlias);
823
852
  return `cd '${shSquoteInner(worktreePath)}' && ${invocation}`;
824
853
  }
825
- /** PowerShell agent shell command: `Set-Location -LiteralPath '<path>'; <agent> '<prompt>'`. */
826
- export function buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove = false) {
827
- const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove }), powershellSquote);
854
+ /** PowerShell agent shell command: `Set-Location -LiteralPath '<path>'; <agent> [--model '<alias>'] '<prompt>'`. */
855
+ export function buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias) {
856
+ const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove }), powershellSquote, modelAlias);
828
857
  return `Set-Location -LiteralPath ${powershellSquote(worktreePath)}; ${invocation}`;
829
858
  }
830
859
  /**
831
860
  * Build the shell command run inside each spawned tab/session, dispatched by
832
861
  * platform. PowerShell on Windows; POSIX everywhere else (incl. the unsupported
833
862
  * dry-run fallback). The selected `agent` (never a module-level constant)
834
- * determines the launched command.
863
+ * determines the launched command. An optional validated `modelAlias` is
864
+ * injected as `--model` at the spawn boundary.
835
865
  */
836
- export function buildAgentShellCommand(agent, key, worktreePath, platform = "darwin", autoApprove = false) {
866
+ export function buildAgentShellCommand(agent, key, worktreePath, platform = "darwin", autoApprove = false, modelAlias) {
837
867
  if (platform === "win32")
838
- return buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove);
839
- return buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove);
868
+ return buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias);
869
+ return buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias);
840
870
  }
841
871
  // ---------------------------------------------------------------------------
842
872
  // macOS terminal spawning (behind the injected boundary)
@@ -1081,7 +1111,7 @@ export async function spawnTabsForCreatedWorktrees(deps, rows, terminal, buildSh
1081
1111
  out.push(row);
1082
1112
  continue;
1083
1113
  }
1084
- const shellCommand = buildShellCommand(row.key, row.path);
1114
+ const shellCommand = buildShellCommand(row.key, row.path, row.modelAlias ?? null);
1085
1115
  const result = await deps.spawnTerminalTab(deps, terminal, shellCommand, {
1086
1116
  key: row.key,
1087
1117
  worktreePath: row.path,
@@ -1115,7 +1145,9 @@ export function buildDryRunResults(keys, overrides) {
1115
1145
  export function getDryRunPlatformDetails(agent, platform = process.platform, env = process.env, autoApprove = false) {
1116
1146
  return {
1117
1147
  worktrunkBinary: resolveWorktrunkBinary(platform, env),
1118
- buildAgentShellCommand: (key, worktreePath) => buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove),
1148
+ // The builder accepts an optional resolved modelAlias; the dry-run caller
1149
+ // now passes the previewed tier's alias so `--model` shows in the preview.
1150
+ buildAgentShellCommand: (key, worktreePath, modelAlias) => buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove, modelAlias),
1119
1151
  };
1120
1152
  }
1121
1153
  /**
@@ -1144,10 +1176,10 @@ export function buildDryRunMcpProvisioningLines(worktreePath, platform = process
1144
1176
  * the secret-free MCP provisioning preview. Pure platform formatting only — no
1145
1177
  * preflight, no routing failures.
1146
1178
  */
1147
- export function buildDryRunDetailLines(agent, key, branch, platform = process.platform, env = process.env, baseBranch = "main", autoApprove = false) {
1179
+ export function buildDryRunDetailLines(agent, key, branch, platform = process.platform, env = process.env, baseBranch = "main", autoApprove = false, modelAlias = null) {
1148
1180
  const { worktrunkBinary, buildAgentShellCommand: build } = getDryRunPlatformDetails(agent, platform, env, autoApprove);
1149
1181
  const wtArgs = buildWtSwitchArgs(branch, false, baseBranch);
1150
- const agentInvocation = build(key, "<worktree-path>");
1182
+ const agentInvocation = build(key, "<worktree-path>", modelAlias);
1151
1183
  return [
1152
1184
  `DRY-RUN: ${key} -> branch=${branch}`,
1153
1185
  `DRY-RUN: ${worktrunkBinary} ${wtArgs.join(" ")}`,
@@ -1235,6 +1267,371 @@ export function buildMcpProvisioningDeps(deps) {
1235
1267
  export async function materializeFileCredentialsForCreatedWorktrees(rows, _deps) {
1236
1268
  return rows;
1237
1269
  }
1270
+ // ---------------------------------------------------------------------------
1271
+ // BAPI-365: difficulty-based model routing (fail-open at the spawn boundary)
1272
+ // ---------------------------------------------------------------------------
1273
+ /** Default Bridge API base URL when BAPI_BASE_URL is unset. */
1274
+ export const START_TICKETS_DEFAULT_BASE_URL = "https://bridgegpt-api.com";
1275
+ const TICKET_MODEL_TIER_FETCH_TIMEOUT_MS = 75_000;
1276
+ const CONFIG_FIELD_FETCH_TIMEOUT_MS = 30_000;
1277
+ const CURSOR_MODEL_LIST_TIMEOUT_MS = 15_000;
1278
+ /** GET auth headers for the CLI's Bridge API calls. */
1279
+ function startTicketsGetHeaders(access) {
1280
+ return {
1281
+ "X-API-Key": access.apiKey,
1282
+ "X-Bridge-MCP-Version": VERSION,
1283
+ };
1284
+ }
1285
+ /**
1286
+ * Resolve the repo name for routing: prefer `BAPI_REPO_NAME`, then `.bridge/config`.
1287
+ * Returns `null` when neither is available.
1288
+ */
1289
+ export async function resolveStartTicketsRepoName(deps) {
1290
+ const fromEnv = deps.env.BAPI_REPO_NAME;
1291
+ if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
1292
+ return fromEnv.trim();
1293
+ }
1294
+ try {
1295
+ const result = await readBridgeConfig(deps.cwd, {
1296
+ readFile: (filePath) => readFile(filePath, "utf-8"),
1297
+ });
1298
+ if (result.ok && result.manifest.repoName) {
1299
+ return result.manifest.repoName;
1300
+ }
1301
+ }
1302
+ catch {
1303
+ // fall through to null
1304
+ }
1305
+ return null;
1306
+ }
1307
+ /**
1308
+ * Resolve Bridge API access (repo name + API key + base URL). Returns a
1309
+ * structured failure — never throwing and never embedding secret values — so the
1310
+ * caller can degrade to the agent default model.
1311
+ */
1312
+ export async function resolveStartTicketsBridgeApiAccess(deps) {
1313
+ const repoName = await resolveStartTicketsRepoName(deps);
1314
+ if (!repoName) {
1315
+ return {
1316
+ ok: false,
1317
+ warning: "model routing: could not resolve repo name (set BAPI_REPO_NAME or .bridge/config); using agent default model",
1318
+ };
1319
+ }
1320
+ let credResult;
1321
+ try {
1322
+ credResult = await resolveBapiCredentials(repoName, {
1323
+ env: deps.env,
1324
+ homedir: os.homedir,
1325
+ platform: deps.platform,
1326
+ readFile: (p) => readFile(p, "utf-8"),
1327
+ stat: (p) => stat(p),
1328
+ });
1329
+ }
1330
+ catch {
1331
+ return { ok: false, warning: "model routing: failed to resolve Bridge API credentials; using agent default model" };
1332
+ }
1333
+ if (!credResult.ok) {
1334
+ return { ok: false, warning: "model routing: Bridge API credentials unavailable; using agent default model" };
1335
+ }
1336
+ const baseUrlRaw = deps.env.BAPI_BASE_URL;
1337
+ const baseUrl = typeof baseUrlRaw === "string" && baseUrlRaw.trim().length > 0
1338
+ ? baseUrlRaw.trim()
1339
+ : START_TICKETS_DEFAULT_BASE_URL;
1340
+ return { ok: true, access: { repoName, apiKey: credResult.credentials.apiKey, baseUrl } };
1341
+ }
1342
+ /** Build a `${baseUrl}/jira${apiPath}` URL with query params; trims trailing slashes. */
1343
+ export function buildStartTicketsJiraUrl(baseUrl, apiPath, params = {}) {
1344
+ const trimmed = baseUrl.replace(/\/+$/, "");
1345
+ const url = new URL(`${trimmed}/jira${apiPath}`);
1346
+ for (const [k, v] of Object.entries(params)) {
1347
+ url.searchParams.set(k, v);
1348
+ }
1349
+ return url.toString();
1350
+ }
1351
+ /**
1352
+ * GET JSON with an `AbortController` timeout. Throws a generic error (no secret
1353
+ * material) on a non-2xx response; always clears the timeout.
1354
+ */
1355
+ export async function fetchJsonWithTimeout(url, headers, timeoutMs) {
1356
+ const controller = new AbortController();
1357
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1358
+ try {
1359
+ const resp = await fetch(url, { headers, signal: controller.signal });
1360
+ if (!resp.ok) {
1361
+ throw new Error(`request failed with status ${resp.status}`);
1362
+ }
1363
+ return await resp.json();
1364
+ }
1365
+ finally {
1366
+ clearTimeout(timer);
1367
+ }
1368
+ }
1369
+ /** Normalize an unknown config value into a clean tier->alias override map. */
1370
+ export function normalizeDifficultyModelTierOverrides(value) {
1371
+ if (value === null || value === undefined)
1372
+ return {};
1373
+ if (typeof value !== "object" || Array.isArray(value))
1374
+ return {};
1375
+ const out = {};
1376
+ for (const [key, raw] of Object.entries(value)) {
1377
+ if (!isModelTier(key))
1378
+ continue;
1379
+ if (typeof raw !== "string")
1380
+ continue;
1381
+ const trimmed = raw.trim();
1382
+ if (trimmed.length === 0)
1383
+ continue;
1384
+ out[key] = trimmed;
1385
+ }
1386
+ return out;
1387
+ }
1388
+ /** GET /jira/config-field/{fieldName}; returns the parsed `value` (or undefined). */
1389
+ export async function fetchStartTicketsConfigField(access, fieldName) {
1390
+ const url = buildStartTicketsJiraUrl(access.baseUrl, `/config-field/${encodeURIComponent(fieldName)}`, { repo_name: access.repoName });
1391
+ const body = await fetchJsonWithTimeout(url, startTicketsGetHeaders(access), CONFIG_FIELD_FETCH_TIMEOUT_MS);
1392
+ if (body && typeof body === "object" && "value" in body) {
1393
+ return body.value;
1394
+ }
1395
+ return undefined;
1396
+ }
1397
+ /**
1398
+ * Fetch the repo's routing config once. A missing/null enable value defaults to
1399
+ * enabled (the feature ships ON); an explicit boolean `false` disables routing.
1400
+ * Any fetch failure returns a structured failure so the caller omits `--model`.
1401
+ */
1402
+ export async function fetchDifficultyModelRoutingConfig(access) {
1403
+ try {
1404
+ const enabledValue = await fetchStartTicketsConfigField(access, "difficulty_model_routing_enabled");
1405
+ const overridesValue = await fetchStartTicketsConfigField(access, "difficulty_model_tier_overrides");
1406
+ const enabled = enabledValue === false ? false : true;
1407
+ return { ok: true, config: { enabled, overrides: normalizeDifficultyModelTierOverrides(overridesValue) } };
1408
+ }
1409
+ catch {
1410
+ return { ok: false, warning: "model routing: failed to fetch routing config; using agent default model" };
1411
+ }
1412
+ }
1413
+ /**
1414
+ * Fetch the coarse tier for one ticket with a bounded timeout. Never throws to
1415
+ * the batch orchestrator: failures/timeouts return a structured warning.
1416
+ */
1417
+ export async function fetchTicketModelTierForStartTickets(access, ticket) {
1418
+ try {
1419
+ const url = buildStartTicketsJiraUrl(access.baseUrl, `/tickets/${encodeURIComponent(ticket)}/model-tier`, { repo_name: access.repoName });
1420
+ const body = await fetchJsonWithTimeout(url, startTicketsGetHeaders(access), TICKET_MODEL_TIER_FETCH_TIMEOUT_MS);
1421
+ const obj = (body ?? {});
1422
+ const tier = isModelTier(obj.tier) ? obj.tier : null;
1423
+ const difficulty = typeof obj.difficulty === "number" ? obj.difficulty : null;
1424
+ const source = obj.source === "cached" || obj.source === "computed" || obj.source === "fallback"
1425
+ ? obj.source
1426
+ : "fallback";
1427
+ return { ok: true, value: { difficulty, tier, source } };
1428
+ }
1429
+ catch {
1430
+ return { ok: false, warning: `model routing: tier lookup failed for ${ticket}; using agent default model` };
1431
+ }
1432
+ }
1433
+ /**
1434
+ * Fetch tiers for the still-spawnable rows with bounded parallelism (capped at
1435
+ * 3). One ticket's failure never aborts the others; results are keyed by ticket.
1436
+ */
1437
+ export async function fetchTicketModelTiersForRows(access, rows, maxParallel, isEligible = (r) => r.status === "created" && !!r.path) {
1438
+ const eligible = rows.filter(isEligible);
1439
+ const limit = Math.min(maxParallel, 3);
1440
+ const results = await runWithConcurrency(eligible, limit, (row) => fetchTicketModelTierForStartTickets(access, row.key).then((res) => [row.key, res]));
1441
+ return new Map(results);
1442
+ }
1443
+ /**
1444
+ * Parse a model-list command's stdout into a set of advertised model aliases.
1445
+ * Tokens are split on whitespace/commas, stripped of surrounding bullets and
1446
+ * punctuation, and kept only when they match the alias allowlist pattern.
1447
+ */
1448
+ export function parseAdvertisedAgentModels(stdout) {
1449
+ const out = new Set();
1450
+ for (const token of stdout.split(/[\s,]+/)) {
1451
+ const cleaned = token.replace(/^[^A-Za-z0-9._:-]+/, "").replace(/[^A-Za-z0-9._:-]+$/, "");
1452
+ // Require at least one alphanumeric char so bare punctuation (e.g. a "-"
1453
+ // bullet) is not mistaken for an alias even though it matches the pattern.
1454
+ if (cleaned.length > 0 && /[A-Za-z0-9]/.test(cleaned) && isValidModelAlias(cleaned)) {
1455
+ out.add(cleaned);
1456
+ }
1457
+ }
1458
+ return out;
1459
+ }
1460
+ /**
1461
+ * Discover the cursor-agent advertised model set by running
1462
+ * `cursor-agent --list-models` (then `cursor-agent models` as a fallback)
1463
+ * non-interactively with a bounded timeout. Returns `null` on failure, timeout,
1464
+ * empty output, or parse uncertainty.
1465
+ */
1466
+ export async function fetchCursorAgentAdvertisedModels(deps, agent) {
1467
+ const tryCommand = async (args) => {
1468
+ try {
1469
+ const result = await deps.runCommand(agent.command, args, {
1470
+ cwd: deps.cwd,
1471
+ timeoutMs: CURSOR_MODEL_LIST_TIMEOUT_MS,
1472
+ });
1473
+ if (!commandSucceeded(result))
1474
+ return null;
1475
+ const models = parseAdvertisedAgentModels(result.stdout);
1476
+ return models.size > 0 ? models : null;
1477
+ }
1478
+ catch {
1479
+ return null;
1480
+ }
1481
+ };
1482
+ const primary = await tryCommand(["--list-models"]);
1483
+ if (primary)
1484
+ return primary;
1485
+ return tryCommand(["models"]);
1486
+ }
1487
+ /**
1488
+ * Validate a resolved alias for the agent before it reaches shell construction.
1489
+ * For non-cursor agents the candidate is returned unchanged (resolveModelAlias
1490
+ * already statically validated claude aliases). For cursor-agent the candidate
1491
+ * must appear in the live advertised model set; otherwise it is rejected.
1492
+ */
1493
+ export async function validateResolvedModelAliasForAgent(deps, agent, candidate) {
1494
+ if (agent.name !== "cursor-agent") {
1495
+ return { ok: true, alias: candidate };
1496
+ }
1497
+ const advertised = await fetchCursorAgentAdvertisedModels(deps, agent);
1498
+ if (!advertised) {
1499
+ return {
1500
+ ok: false,
1501
+ warning: `model routing: could not verify cursor-agent models; omitting --model for '${candidate}'`,
1502
+ };
1503
+ }
1504
+ if (!advertised.has(candidate)) {
1505
+ return {
1506
+ ok: false,
1507
+ warning: `model routing: cursor-agent model '${candidate}' not advertised; omitting --model`,
1508
+ };
1509
+ }
1510
+ return { ok: true, alias: candidate };
1511
+ }
1512
+ /** Return a copy of `row` with default (no-routing) metadata and a reason. */
1513
+ export function applyDefaultModelRoutingMetadata(row, reason) {
1514
+ return {
1515
+ ...row,
1516
+ difficulty: row.difficulty ?? null,
1517
+ modelTier: null,
1518
+ modelAlias: null,
1519
+ modelRoutingSource: row.modelRoutingSource ?? "default",
1520
+ modelRoutingReason: reason,
1521
+ };
1522
+ }
1523
+ /** Real spawn path: a worktree was created and has a path. */
1524
+ const isCreatedRoutingEligible = (r) => r.status === "created" && !!r.path;
1525
+ /** Dry-run path: rows have no worktree path yet but are eligible for a preview. */
1526
+ const isDryRunRoutingEligible = (r) => r.status === "dry-run";
1527
+ /**
1528
+ * Shared fail-open routing resolution. `isEligible` selects which rows get a
1529
+ * resolved tier/alias; every failure mode (unsupported agent, credential/config/
1530
+ * tier fetch failure, backend fallback, invalid/unavailable alias) is converted
1531
+ * to `modelAlias: null` plus a row warning, and never throws. Non-eligible rows
1532
+ * pass through untouched.
1533
+ */
1534
+ async function resolveModelRoutingForEligible(deps, rows, options, agent, isEligible) {
1535
+ const eligible = rows.filter(isEligible);
1536
+ if (eligible.length === 0)
1537
+ return rows;
1538
+ if (!agent.supportsModelOverride) {
1539
+ const reason = `model routing: agent '${agent.name}' does not support --model; using agent default`;
1540
+ return rows.map((r) => (isEligible(r) ? applyDefaultModelRoutingMetadata(r, reason) : r));
1541
+ }
1542
+ const accessResult = await resolveStartTicketsBridgeApiAccess(deps);
1543
+ if (!accessResult.ok) {
1544
+ return rows.map((r) => isEligible(r)
1545
+ ? appendSummaryRowWarning(applyDefaultModelRoutingMetadata(r, accessResult.warning), accessResult.warning)
1546
+ : r);
1547
+ }
1548
+ const access = accessResult.access;
1549
+ const configResult = await fetchDifficultyModelRoutingConfig(access);
1550
+ if (!configResult.ok) {
1551
+ return rows.map((r) => isEligible(r)
1552
+ ? appendSummaryRowWarning(applyDefaultModelRoutingMetadata(r, configResult.warning), configResult.warning)
1553
+ : r);
1554
+ }
1555
+ const { enabled, overrides } = configResult.config;
1556
+ if (!enabled) {
1557
+ const reason = "model routing: disabled for this repo; using agent default";
1558
+ return rows.map((r) => (isEligible(r) ? applyDefaultModelRoutingMetadata(r, reason) : r));
1559
+ }
1560
+ const tierMap = await fetchTicketModelTiersForRows(access, eligible, options.maxParallel, isEligible);
1561
+ const out = [];
1562
+ for (const row of rows) {
1563
+ if (!isEligible(row)) {
1564
+ out.push(row);
1565
+ continue;
1566
+ }
1567
+ const tierResult = tierMap.get(row.key);
1568
+ if (!tierResult || !tierResult.ok) {
1569
+ const warning = tierResult && !tierResult.ok
1570
+ ? tierResult.warning
1571
+ : `model routing: no tier resolved for ${row.key}; using agent default`;
1572
+ out.push(appendSummaryRowWarning(applyDefaultModelRoutingMetadata(row, warning), warning));
1573
+ continue;
1574
+ }
1575
+ const { difficulty, tier, source } = tierResult.value;
1576
+ const baseRow = {
1577
+ ...row,
1578
+ difficulty: difficulty ?? null,
1579
+ modelTier: tier ?? null,
1580
+ modelRoutingSource: source,
1581
+ };
1582
+ if (!tier) {
1583
+ const warning = `model routing: ${row.key} resolved no tier (source=${source}); using agent default`;
1584
+ out.push(appendSummaryRowWarning(applyDefaultModelRoutingMetadata(baseRow, warning), warning));
1585
+ continue;
1586
+ }
1587
+ const alias = resolveModelAlias(agent, tier, overrides);
1588
+ if (!alias) {
1589
+ const warning = `model routing: no valid alias for tier=${tier}; using agent default`;
1590
+ out.push(appendSummaryRowWarning({ ...baseRow, modelAlias: null, modelRoutingReason: warning }, warning));
1591
+ continue;
1592
+ }
1593
+ const validation = await validateResolvedModelAliasForAgent(deps, agent, alias);
1594
+ if (!validation.ok) {
1595
+ out.push(appendSummaryRowWarning({ ...baseRow, modelAlias: null, modelRoutingReason: validation.warning }, validation.warning));
1596
+ continue;
1597
+ }
1598
+ out.push({
1599
+ ...baseRow,
1600
+ modelAlias: validation.alias,
1601
+ modelRoutingReason: `tier=${tier} model=${validation.alias} (source=${source})`,
1602
+ });
1603
+ }
1604
+ return out;
1605
+ }
1606
+ /**
1607
+ * Resolve per-ticket model routing for the spawnable (created) rows. Public
1608
+ * entrypoint for the real spawn path. ALWAYS fail-open (see
1609
+ * {@link resolveModelRoutingForEligible}).
1610
+ */
1611
+ export async function resolveModelRoutingForRows(deps, rows, options, agent) {
1612
+ return resolveModelRoutingForEligible(deps, rows, options, agent, isCreatedRoutingEligible);
1613
+ }
1614
+ /**
1615
+ * Dry-run variant: resolve routing for `dry-run` rows so `--dry-run` can preview
1616
+ * the model each tab would launch with. Identical fail-open resolution; only the
1617
+ * eligibility differs (dry-run rows carry no worktree path). NOTE: this performs
1618
+ * the same read-only tier lookup as a real run, so for a ticket with no cached
1619
+ * difficulty the backend may compute and cache it (one LLM call) — exactly what
1620
+ * the subsequent real run would have done.
1621
+ */
1622
+ export async function resolveModelRoutingForDryRun(deps, rows, options, agent) {
1623
+ return resolveModelRoutingForEligible(deps, rows, options, agent, isDryRunRoutingEligible);
1624
+ }
1625
+ /**
1626
+ * Format one concise routing decision line per ticket:
1627
+ * `KEY difficulty=<n|?> tier=<tier|fallback> agent=<name> model=<alias|default>`.
1628
+ */
1629
+ export function formatModelRoutingLine(row, agent) {
1630
+ const difficulty = typeof row.difficulty === "number" ? String(row.difficulty) : "?";
1631
+ const tier = row.modelTier ?? "fallback";
1632
+ const model = row.modelAlias ?? "default";
1633
+ return `${row.key} difficulty=${difficulty} tier=${tier} agent=${agent.name} model=${model}`;
1634
+ }
1238
1635
  export async function orchestrateStartTickets(deps, options, overrides = {}) {
1239
1636
  if (options.dryRun) {
1240
1637
  return { ok: true, rows: buildDryRunResults(options.keys, options.branchOverrides) };
@@ -1276,8 +1673,21 @@ export async function orchestrateStartTickets(deps, options, overrides = {}) {
1276
1673
  // and BEFORE tab spawning (currently a pass-through seam; see
1277
1674
  // materializeFileCredentialsForCreatedWorktrees).
1278
1675
  const materialized = await materializeFn(provisioned, deps);
1676
+ // BAPI-365: resolve per-ticket model routing AFTER provisioning/materialization
1677
+ // and BEFORE building the spawn command. Always fail-open: routing never aborts
1678
+ // a spawn — at worst a ticket runs on the agent's default model.
1679
+ const resolveRoutingFn = overrides.resolveModelRoutingForRows ?? resolveModelRoutingForRows;
1680
+ const routed = await resolveRoutingFn(deps, materialized, options, agent);
1681
+ for (const row of routed) {
1682
+ if (row.status !== "created" || !row.path)
1683
+ continue;
1684
+ overrides.modelRoutingLog?.(formatModelRoutingLine(row, agent));
1685
+ if (row.modelAlias == null && row.modelRoutingReason) {
1686
+ overrides.modelRoutingWarningLog?.(`${row.key}: ${row.modelRoutingReason}`);
1687
+ }
1688
+ }
1279
1689
  const terminal = detectTerminalFn(options.terminal, deps.env);
1280
- const rows = await spawnTabsFn(deps, materialized, terminal, platformConfig.config.buildAgentShellCommand);
1690
+ const rows = await spawnTabsFn(deps, routed, terminal, platformConfig.config.buildAgentShellCommand);
1281
1691
  return { ok: true, rows };
1282
1692
  }
1283
1693
  /** Platform-specific guidance printed when one or more tabs fail to spawn. */
@@ -1329,15 +1739,34 @@ export async function runStartTicketsCli(argv, overrides = {}) {
1329
1739
  return 1;
1330
1740
  }
1331
1741
  if (options.dryRun) {
1742
+ // Preview model routing too: resolve tiers read-only (fail-open) so the
1743
+ // dry-run shows the exact `--model` each tab would launch with, plus a
1744
+ // per-ticket routing decision line.
1745
+ const resolveDryRunRoutingFn = overrides.resolveModelRoutingForDryRun ?? resolveModelRoutingForDryRun;
1746
+ const dryRunRows = buildDryRunResults(options.keys, options.branchOverrides);
1747
+ const routedDryRunRows = await resolveDryRunRoutingFn(deps, dryRunRows, options, agent);
1748
+ const routedByKey = new Map(routedDryRunRows.map((r) => [r.key, r]));
1332
1749
  for (const key of options.keys) {
1333
1750
  const branch = resolveBranchForTicket(key, options.branchOverrides);
1334
- for (const line of buildDryRunDetailLines(agent, key, branch, deps.platform, deps.env, options.baseBranch, options.autoApprove)) {
1751
+ const routedRow = routedByKey.get(key);
1752
+ const modelAlias = routedRow?.modelAlias ?? null;
1753
+ for (const line of buildDryRunDetailLines(agent, key, branch, deps.platform, deps.env, options.baseBranch, options.autoApprove, modelAlias)) {
1335
1754
  log(line);
1336
1755
  }
1756
+ log(`DRY-RUN: model routing: ${formatModelRoutingLine(routedRow ?? { key, branch, status: "dry-run" }, agent)}`);
1757
+ if (modelAlias == null && routedRow?.modelRoutingReason) {
1758
+ log(`DRY-RUN: ${routedRow.modelRoutingReason}`);
1759
+ }
1337
1760
  }
1338
1761
  log("");
1339
1762
  }
1340
- const result = await orchestrate(deps, options);
1763
+ // Thread the routing decision/warning sinks through to the orchestrator. The
1764
+ // dry-run path short-circuits inside orchestrate before any routing (the
1765
+ // preview above already resolved it), so no backend tiers are fetched twice.
1766
+ const result = await orchestrate(deps, options, {
1767
+ modelRoutingLog: log,
1768
+ modelRoutingWarningLog: errorLog,
1769
+ });
1341
1770
  if (!result.ok) {
1342
1771
  errorLog(`Error: ${result.error}`);
1343
1772
  return 1;
@@ -1,2 +1,2 @@
1
1
  // AUTO-GENERATED — do not edit manually. Regenerate with: npm run build
2
- export const VERSION = "0.2.0";
2
+ export const VERSION = "0.2.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge_gpt/mcp-server",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Bridge API MCP server — exposes Jira endpoints as MCP tools for Claude Code agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -17,10 +17,11 @@
17
17
  "LICENSE"
18
18
  ],
19
19
  "scripts": {
20
- "build": "node scripts/bundle-version.js && node scripts/bundle-pipelines.js && node scripts/bundle-commands.js && node scripts/bundle-agents.js && tsc",
20
+ "build": "node scripts/bundle-version.js && node scripts/bundle-readme.js && node scripts/bundle-pipelines.js && node scripts/bundle-commands.js && node scripts/bundle-agents.js && tsc",
21
+ "check:version-generated": "node scripts/bundle-version.js && node scripts/check-version-generated.js",
21
22
  "postbuild": "node scripts/prepend-shebang.cjs",
22
23
  "start": "node build/index.js",
23
- "test": "node --test build/pipeline-utils.test.js build/update-check.test.js build/cli-upgrade.test.js build/decision-page-schema.test.js build/decision-page-template.test.js build/bundle-pipelines.test.js build/instructions-contract.test.js build/pipeline-orchestrator-persistence.test.js build/pipeline-orchestrator-execution.test.js build/pipeline-orchestrator-integration.test.js build/index-static.test.js build/index-resolvers.test.js build/index-project-root.test.js build/index-pipelines.test.js build/index.test.js build/bridge-config.test.js build/credential-store.test.js build/mcp-invoke.test.js build/mcp-provisioning.test.js build/third-party-mcp-targets.test.js build/git-ignore-utils.test.js build/credential-materialization.test.js build/mcp-registration-doctor.test.js build/secret-safety.test.js build/start-tickets.test.js build/start-tickets-base-branch.test.js build/agent-registry.test.js build/start-tickets-prereqs.test.js build/doctor.test.js build/package-static.test.js build/chain-utils.test.js build/chain-orchestrator.test.js build/scheduler-backends/types.test.js build/scheduler-backends/escaping.test.js build/scheduler-backends/launchd.test.js build/scheduler-backends/task-scheduler.test.js build/scheduler-backends/systemd-user.test.js build/scheduler-backends/at-fallback.test.js build/scheduler-backends/index.test.js build/agent-launchers/claude.test.js build/agent-launchers/index.test.js build/schedule-store.test.js build/schedule-run.test.js build/agent-capabilities/cli.test.js build/agent-capabilities/runner.test.js build/agent-capabilities/probes.test.js build/agent-capabilities/reporter.test.js && node --experimental-test-module-mocks --test build/index-heavy-read-truncation.test.js build/index-artifacts.test.js build/index-brainstorm-filenames.test.js build/index-output-path.test.js build/index-generate-decision-page.test.js build/index-generate-decision-page.integration.test.js",
24
+ "test": "node --test build/pipeline-utils.test.js build/update-check.test.js build/cli-upgrade.test.js build/decision-page-schema.test.js build/decision-page-template.test.js build/bundle-pipelines.test.js build/instructions-contract.test.js build/pipeline-orchestrator-persistence.test.js build/pipeline-orchestrator-execution.test.js build/pipeline-orchestrator-integration.test.js build/index-static.test.js build/index-resolvers.test.js build/index-project-root.test.js build/index-pipelines.test.js build/index.test.js build/bridge-config.test.js build/credential-store.test.js build/mcp-invoke.test.js build/mcp-provisioning.test.js build/third-party-mcp-targets.test.js build/git-ignore-utils.test.js build/credential-materialization.test.js build/mcp-registration-doctor.test.js build/secret-safety.test.js build/start-tickets.test.js build/start-tickets-base-branch.test.js build/agent-registry.test.js build/agent-registry.model-routing.test.js build/start-tickets.shell-model-routing.test.js build/start-tickets.bridge-api-model-routing.test.js build/start-tickets.tier-fetch-model-routing.test.js build/start-tickets.resolve-model-routing.test.js build/start-tickets.orchestrate-model-routing.test.js build/start-tickets-prereqs.test.js build/doctor.test.js build/package-static.test.js build/chain-utils.test.js build/chain-orchestrator.test.js build/scheduler-backends/types.test.js build/scheduler-backends/escaping.test.js build/scheduler-backends/launchd.test.js build/scheduler-backends/task-scheduler.test.js build/scheduler-backends/systemd-user.test.js build/scheduler-backends/at-fallback.test.js build/scheduler-backends/index.test.js build/command-catalog.test.js build/scheduled-prompt.test.js build/agent-launchers/claude.test.js build/agent-launchers/cursor.test.js build/agent-launchers/index.test.js build/schedule-store.test.js build/schedule-run.test.js build/agent-capabilities/cli.test.js build/agent-capabilities/runner.test.js build/agent-capabilities/probes.test.js build/agent-capabilities/reporter.test.js && node --experimental-test-module-mocks --test build/index-heavy-read-truncation.test.js build/index-artifacts.test.js build/index-brainstorm-filenames.test.js build/index-output-path.test.js build/index-generate-decision-page.test.js build/index-generate-decision-page.integration.test.js",
24
25
  "test:integration": "node --test build/integration/refresh-main.integration.test.js build/integration/start-tickets.integration.test.js build/integration/doctor.integration.test.js build/integration/agent-capabilities.integration.test.js",
25
26
  "prepublishOnly": "npm run build && node scripts/verify-shebang.cjs"
26
27
  },