@bridge_gpt/mcp-server 0.1.17 → 0.2.1

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 (45) hide show
  1. package/README.md +334 -196
  2. package/build/agent-capabilities/cli.js +152 -0
  3. package/build/agent-capabilities/default-deps.js +45 -0
  4. package/build/agent-capabilities/probe-context.js +111 -0
  5. package/build/agent-capabilities/probes.js +278 -0
  6. package/build/agent-capabilities/reporter.js +50 -0
  7. package/build/agent-capabilities/runner.js +56 -0
  8. package/build/agent-capabilities/types.js +10 -0
  9. package/build/agent-launchers/claude.js +25 -17
  10. package/build/agent-launchers/cursor.js +65 -0
  11. package/build/agent-launchers/index.js +23 -8
  12. package/build/agent-registry.js +68 -0
  13. package/build/agents.generated.js +1 -1
  14. package/build/brainstorm-files.js +89 -0
  15. package/build/bridge-config.js +404 -0
  16. package/build/chain-orchestrator.js +247 -33
  17. package/build/command-catalog.js +376 -0
  18. package/build/commands.generated.js +10 -7
  19. package/build/credential-materialization.js +128 -0
  20. package/build/credential-store.js +232 -0
  21. package/build/decision-page-schema.js +39 -6
  22. package/build/decision-page-template.js +54 -18
  23. package/build/doctor.js +18 -2
  24. package/build/git-ignore-utils.js +63 -0
  25. package/build/index.js +1707 -557
  26. package/build/mcp-invoke.js +417 -0
  27. package/build/mcp-provisioning.js +342 -0
  28. package/build/mcp-registration-doctor.js +96 -0
  29. package/build/pipeline-orchestrator.js +9 -1
  30. package/build/pipelines.generated.js +5 -3
  31. package/build/schedule-run.js +440 -92
  32. package/build/schedule-store.js +41 -1
  33. package/build/scheduled-prompt.js +109 -0
  34. package/build/scheduler-backends/at-fallback.js +5 -10
  35. package/build/scheduler-backends/escaping.js +40 -10
  36. package/build/scheduler-backends/launchd.js +23 -14
  37. package/build/scheduler-backends/systemd-user.js +32 -19
  38. package/build/scheduler-backends/task-scheduler.js +8 -13
  39. package/build/start-tickets-prereqs.js +90 -1
  40. package/build/start-tickets.js +563 -42
  41. package/build/third-party-mcp-targets.js +75 -0
  42. package/build/version.generated.js +1 -1
  43. package/package.json +4 -3
  44. package/pipelines/full-automation.json +3 -1
  45. package/smoke-test/SMOKE-TEST.md +62 -17
@@ -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,13 +54,19 @@
53
54
  * unit-testable on Linux CI without spawning real commands or terminals.
54
55
  */
55
56
  import { execFile } from "child_process";
57
+ import { readFile, writeFile, mkdir, stat } from "fs/promises";
58
+ import os from "node:os";
56
59
  import path from "path";
60
+ import { VERSION } from "./version.generated.js";
61
+ import { resolveBapiCredentials } from "./credential-store.js";
62
+ import { readBridgeConfig } from "./bridge-config.js";
63
+ import { provisionMcpRegistrationsForCreatedWorktrees, } from "./mcp-provisioning.js";
57
64
  // Per-OS prerequisite knowledge + low-level command probes live in the shared
58
65
  // prereqs module so `runPreflight` (enforce) and the read-only `doctor` (render)
59
66
  // can never drift. `start-tickets.ts` imports VALUES from there; the prereqs
60
67
  // module imports only TYPES back, so the runtime graph stays acyclic.
61
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";
62
- 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";
63
70
  // Re-export the shared prereq surface (constants, platform helpers, command
64
71
  // probes) so existing import sites that read them from "./start-tickets.js"
65
72
  // keep working unchanged.
@@ -75,6 +82,10 @@ export const DEFAULT_MAX_PARALLEL = 3;
75
82
  export const DEFAULT_TMUX_SESSION_PREFIX = "bridge-start-tickets";
76
83
  /** Environment variable overriding the tmux session-name prefix. */
77
84
  export const TMUX_SESSION_OVERRIDE_ENV = "BAPI_TMUX_SESSION";
85
+ /** Return a copy of `row` with `warning` appended; never mutates the input. */
86
+ export function appendSummaryRowWarning(row, warning) {
87
+ return { ...row, warnings: [...(row.warnings ?? []), warning] };
88
+ }
78
89
  // ---------------------------------------------------------------------------
79
90
  // Usage / argument parsing
80
91
  // ---------------------------------------------------------------------------
@@ -87,7 +98,7 @@ export function getStartTicketsUsage() {
87
98
  "Flags:",
88
99
  " --agent claude|cursor-agent Agent command to launch in each worktree (default: claude)",
89
100
  " --terminal terminal|iterm Override the macOS terminal app (default: auto-detect via $TERM_PROGRAM); honored on macOS only",
90
- " --dry-run Print intended actions; create no worktrees, open no tabs",
101
+ " --dry-run Print intended actions (incl. resolved model routing); create no worktrees, open no tabs",
91
102
  " --branch KEY=BRANCH Use BRANCH instead of feature/KEY for that ticket (repeatable)",
92
103
  " --base-branch BRANCH Cut new worktrees from BRANCH and refresh origin/BRANCH (default: main)",
93
104
  " --no-refresh-main Skip refresh of the configured base branch (default main); historical name retained for backward compatibility",
@@ -227,7 +238,7 @@ export function parseStartTicketsArgs(argv) {
227
238
  dryRun = true;
228
239
  continue;
229
240
  }
230
- if (arg === "--auto-approve") {
241
+ if (arg === "--auto") {
231
242
  autoApprove = true;
232
243
  continue;
233
244
  }
@@ -384,7 +395,7 @@ export function resolveStartTicketsPlatformConfig(deps, agent, autoApprove = fal
384
395
  config: {
385
396
  platform,
386
397
  worktrunkBinary: resolveWorktrunkBinary(platform, deps.env),
387
- buildAgentShellCommand: (key, worktreePath) => buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove),
398
+ buildAgentShellCommand: (key, worktreePath, modelAlias) => buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove, modelAlias),
388
399
  spawnTerminalTab: deps.spawnTerminalTab,
389
400
  },
390
401
  };
@@ -458,6 +469,8 @@ export function createDefaultStartTicketsDeps() {
458
469
  // Worktrunk JSON / git porcelain output can be large; give it room.
459
470
  maxBuffer: 64 * 1024 * 1024,
460
471
  encoding: "utf-8",
472
+ // Optional bounded timeout (e.g. cursor-agent --list-models probes).
473
+ timeout: options?.timeoutMs,
461
474
  }, (error, stdout, stderr) => {
462
475
  const exitCode = error && typeof error.code === "number"
463
476
  ? (error.code)
@@ -789,47 +802,71 @@ export async function createWorktrees(deps, options, worktrunkBinary) {
789
802
  /**
790
803
  * The starter prompt handed to the selected agent. Identical for every agent.
791
804
  * When `autoApprove` is set, the implementation agent runs hands-off
792
- * (`/implement-ticket <KEY> --auto-approve`) — used by full-automation chains.
805
+ * (`/implement-ticket <KEY> --auto`) — used by full-automation chains.
793
806
  */
794
807
  export function buildAgentPrompt(key, opts = {}) {
795
- return `/implement-ticket ${key}${opts.autoApprove ? " --auto-approve" : ""}`;
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.
811
+ return `/implement-ticket ${key}${opts.autoApprove ? " --auto" : ""}`;
812
+ }
813
+ /**
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.
819
+ */
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;
796
829
  }
797
830
  /**
798
- * Build the agent invocation (`<command> <quotedPrompt>`) for the agent's prompt
799
- * style. Only `positional` exists today; the `switch` keeps a future flag-style
800
- * agent a typed extension rather than a silent fall-through. `quote` applies the
801
- * platform-correct quoting to the prompt.
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`.
802
835
  */
803
- export function buildAgentInvocation(agent, prompt, quote) {
836
+ export function buildAgentInvocation(agent, prompt, quote, modelAlias) {
804
837
  switch (agent.promptArgStyle) {
805
- case "positional":
806
- 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
+ }
807
843
  default: {
808
844
  const exhaustive = agent.promptArgStyle;
809
845
  throw new Error(`Unsupported agent promptArgStyle: ${String(exhaustive)}`);
810
846
  }
811
847
  }
812
848
  }
813
- /** POSIX agent shell command: `cd '<path>' && <agent> '<prompt>'`. */
814
- export function buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove = false) {
815
- 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);
816
852
  return `cd '${shSquoteInner(worktreePath)}' && ${invocation}`;
817
853
  }
818
- /** PowerShell agent shell command: `Set-Location -LiteralPath '<path>'; <agent> '<prompt>'`. */
819
- export function buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove = false) {
820
- 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);
821
857
  return `Set-Location -LiteralPath ${powershellSquote(worktreePath)}; ${invocation}`;
822
858
  }
823
859
  /**
824
860
  * Build the shell command run inside each spawned tab/session, dispatched by
825
861
  * platform. PowerShell on Windows; POSIX everywhere else (incl. the unsupported
826
862
  * dry-run fallback). The selected `agent` (never a module-level constant)
827
- * determines the launched command.
863
+ * determines the launched command. An optional validated `modelAlias` is
864
+ * injected as `--model` at the spawn boundary.
828
865
  */
829
- export function buildAgentShellCommand(agent, key, worktreePath, platform = "darwin", autoApprove = false) {
866
+ export function buildAgentShellCommand(agent, key, worktreePath, platform = "darwin", autoApprove = false, modelAlias) {
830
867
  if (platform === "win32")
831
- return buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove);
832
- return buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove);
868
+ return buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias);
869
+ return buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias);
833
870
  }
834
871
  // ---------------------------------------------------------------------------
835
872
  // macOS terminal spawning (behind the injected boundary)
@@ -1074,7 +1111,7 @@ export async function spawnTabsForCreatedWorktrees(deps, rows, terminal, buildSh
1074
1111
  out.push(row);
1075
1112
  continue;
1076
1113
  }
1077
- const shellCommand = buildShellCommand(row.key, row.path);
1114
+ const shellCommand = buildShellCommand(row.key, row.path, row.modelAlias ?? null);
1078
1115
  const result = await deps.spawnTerminalTab(deps, terminal, shellCommand, {
1079
1116
  key: row.key,
1080
1117
  worktreePath: row.path,
@@ -1108,22 +1145,46 @@ export function buildDryRunResults(keys, overrides) {
1108
1145
  export function getDryRunPlatformDetails(agent, platform = process.platform, env = process.env, autoApprove = false) {
1109
1146
  return {
1110
1147
  worktrunkBinary: resolveWorktrunkBinary(platform, env),
1111
- 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),
1112
1151
  };
1113
1152
  }
1153
+ /**
1154
+ * Build the dry-run preview of the secret-free MCP provisioning that
1155
+ * `start-tickets` would perform for a worktree whose `.bridge/config` includes
1156
+ * the `bapi` target. Lists both registration files and the version-pinned shim
1157
+ * command. Pure formatting — implies no credentials and writes no files.
1158
+ */
1159
+ export function buildDryRunMcpProvisioningLines(worktreePath, platform = process.platform) {
1160
+ const api = platform === "win32" ? path.win32 : path.posix;
1161
+ const mcpJson = api.join(worktreePath, ".mcp.json");
1162
+ const cursorJson = api.join(worktreePath, ".cursor", "mcp.json");
1163
+ const shim = `npx -y @bridge_gpt/mcp-server@${VERSION} mcp-invoke --target <target> --project-root ${worktreePath}`;
1164
+ return [
1165
+ "DRY-RUN: MCP provisioning (target-driven from .bridge/config — bapi plus any",
1166
+ "DRY-RUN: supported Tier-2 target such as sfcc): would write a secret-free shim",
1167
+ "DRY-RUN: entry per target to",
1168
+ `DRY-RUN: ${mcpJson}`,
1169
+ `DRY-RUN: ${cursorJson}`,
1170
+ `DRY-RUN: ${shim}`,
1171
+ ];
1172
+ }
1114
1173
  /**
1115
1174
  * Build the user-facing dry-run detail lines for one ticket, rendering the
1116
- * platform-correct Worktrunk binary and the selected agent's shell command. Pure
1117
- * platform formatting only — no preflight, no routing failures.
1175
+ * platform-correct Worktrunk binary, the selected agent's shell command, and
1176
+ * the secret-free MCP provisioning preview. Pure platform formatting only — no
1177
+ * preflight, no routing failures.
1118
1178
  */
1119
- 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) {
1120
1180
  const { worktrunkBinary, buildAgentShellCommand: build } = getDryRunPlatformDetails(agent, platform, env, autoApprove);
1121
1181
  const wtArgs = buildWtSwitchArgs(branch, false, baseBranch);
1122
- const agentInvocation = build(key, "<worktree-path>");
1182
+ const agentInvocation = build(key, "<worktree-path>", modelAlias);
1123
1183
  return [
1124
1184
  `DRY-RUN: ${key} -> branch=${branch}`,
1125
1185
  `DRY-RUN: ${worktrunkBinary} ${wtArgs.join(" ")}`,
1126
1186
  `DRY-RUN: ${agentInvocation}`,
1187
+ ...buildDryRunMcpProvisioningLines("<worktree-path>", platform),
1127
1188
  ];
1128
1189
  }
1129
1190
  /**
@@ -1140,13 +1201,30 @@ export function formatSummaryReport(rows) {
1140
1201
  line += ` path=${row.path}`;
1141
1202
  lines.push(line);
1142
1203
  }
1143
- const failures = rows.filter((r) => r.status === "create-failed" || r.status === "spawn-failed");
1144
- if (failures.length > 0) {
1204
+ // Warnings section: create/spawn-failed row errors AND any non-fatal
1205
+ // per-row provisioning warnings. Identical error/warning text for the same
1206
+ // row is emitted only once.
1207
+ const warningLines = [];
1208
+ for (const row of rows) {
1209
+ const messages = [];
1210
+ if (row.status === "create-failed" || row.status === "spawn-failed") {
1211
+ messages.push(row.error ?? row.status);
1212
+ }
1213
+ for (const warning of row.warnings ?? []) {
1214
+ messages.push(warning);
1215
+ }
1216
+ const seen = new Set();
1217
+ for (const message of messages) {
1218
+ if (seen.has(message))
1219
+ continue;
1220
+ seen.add(message);
1221
+ warningLines.push(` ${row.key}: ${message}`);
1222
+ }
1223
+ }
1224
+ if (warningLines.length > 0) {
1145
1225
  lines.push("");
1146
1226
  lines.push("Warnings:");
1147
- for (const row of failures) {
1148
- lines.push(` ${row.key}: ${row.error ?? row.status}`);
1149
- }
1227
+ lines.push(...warningLines);
1150
1228
  }
1151
1229
  return lines.join("\n");
1152
1230
  }
@@ -1158,7 +1236,403 @@ export function formatSummaryReport(rows) {
1158
1236
  * using the platform-correct shell builder. Global preflight/refresh failures
1159
1237
  * are returned as `{ ok: false }`; per-ticket failures stay in the rows.
1160
1238
  */
1161
- export async function orchestrateStartTickets(deps, options) {
1239
+ /**
1240
+ * Build the `mcp-provisioning` filesystem boundary from `StartTicketsDeps`.
1241
+ * Provisioning needs real `fs/promises` ops (not part of the command-runner DI
1242
+ * boundary), but inherits the platform and cwd from the orchestration deps.
1243
+ */
1244
+ export function buildMcpProvisioningDeps(deps) {
1245
+ return {
1246
+ readFile: (filePath) => readFile(filePath, "utf-8"),
1247
+ writeFile: (filePath, data) => writeFile(filePath, data, "utf-8"),
1248
+ mkdir: (dirPath, options) => mkdir(dirPath, options),
1249
+ platform: deps.platform,
1250
+ cwd: deps.cwd,
1251
+ };
1252
+ }
1253
+ /**
1254
+ * Tier-3 file-credential materialization seam.
1255
+ *
1256
+ * The committed `.bridge/config` schema does not (yet) declare file-credential
1257
+ * entries — Tier-3 file materialization is a gated seam (see
1258
+ * `credential-materialization.ts`, whose Windows worktree-visible copy remains
1259
+ * disabled pending the open context-vs-server-only design decision). With no
1260
+ * file-credential configuration there is nothing to materialize, so the default
1261
+ * is a safe pass-through. The seam exists so `orchestrateStartTickets` calls it
1262
+ * in the correct order — AFTER MCP registration provisioning and BEFORE tab
1263
+ * spawning — once the schema and the design decision are resolved. Per-row
1264
+ * failures must mark only the affected row `spawn-failed`; normal symlinks are
1265
+ * never torn down on completion.
1266
+ */
1267
+ export async function materializeFileCredentialsForCreatedWorktrees(rows, _deps) {
1268
+ return rows;
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
+ }
1635
+ export async function orchestrateStartTickets(deps, options, overrides = {}) {
1162
1636
  if (options.dryRun) {
1163
1637
  return { ok: true, rows: buildDryRunResults(options.keys, options.branchOverrides) };
1164
1638
  }
@@ -1183,9 +1657,37 @@ export async function orchestrateStartTickets(deps, options) {
1183
1657
  });
1184
1658
  if (!refresh.ok)
1185
1659
  return { ok: false, error: refresh.error };
1186
- const created = await createWorktrees(deps, options, platformConfig.config.worktrunkBinary);
1187
- const terminal = detectTerminal(options.terminal, deps.env);
1188
- const rows = await spawnTabsForCreatedWorktrees(deps, created, terminal, platformConfig.config.buildAgentShellCommand);
1660
+ const createWorktreesFn = overrides.createWorktrees ?? createWorktrees;
1661
+ const provisionFn = overrides.provisionMcpRegistrations ??
1662
+ ((rows, d) => provisionMcpRegistrationsForCreatedWorktrees(rows, buildMcpProvisioningDeps(d)));
1663
+ const materializeFn = overrides.materializeFileCredentials ?? materializeFileCredentialsForCreatedWorktrees;
1664
+ const detectTerminalFn = overrides.detectTerminal ?? detectTerminal;
1665
+ const spawnTabsFn = overrides.spawnTabsForCreatedWorktrees ?? spawnTabsForCreatedWorktrees;
1666
+ const created = await createWorktreesFn(deps, options, platformConfig.config.worktrunkBinary);
1667
+ // Synchronously provision secret-free worktree MCP registrations after
1668
+ // worktree creation and before launching the agent tab. Per-worktree
1669
+ // provisioning failures mark only that row `spawn-failed` (skipped by the
1670
+ // spawn step below) — they never abort the whole run.
1671
+ const provisioned = await provisionFn(created, deps);
1672
+ // Materialize any Tier-3 file credentials AFTER MCP registration provisioning
1673
+ // and BEFORE tab spawning (currently a pass-through seam; see
1674
+ // materializeFileCredentialsForCreatedWorktrees).
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
+ }
1689
+ const terminal = detectTerminalFn(options.terminal, deps.env);
1690
+ const rows = await spawnTabsFn(deps, routed, terminal, platformConfig.config.buildAgentShellCommand);
1189
1691
  return { ok: true, rows };
1190
1692
  }
1191
1693
  /** Platform-specific guidance printed when one or more tabs fail to spawn. */
@@ -1237,15 +1739,34 @@ export async function runStartTicketsCli(argv, overrides = {}) {
1237
1739
  return 1;
1238
1740
  }
1239
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]));
1240
1749
  for (const key of options.keys) {
1241
1750
  const branch = resolveBranchForTicket(key, options.branchOverrides);
1242
- 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)) {
1243
1754
  log(line);
1244
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
+ }
1245
1760
  }
1246
1761
  log("");
1247
1762
  }
1248
- 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
+ });
1249
1770
  if (!result.ok) {
1250
1771
  errorLog(`Error: ${result.error}`);
1251
1772
  return 1;