@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.
- package/README.md +334 -196
- package/build/agent-capabilities/cli.js +152 -0
- package/build/agent-capabilities/default-deps.js +45 -0
- package/build/agent-capabilities/probe-context.js +111 -0
- package/build/agent-capabilities/probes.js +278 -0
- package/build/agent-capabilities/reporter.js +50 -0
- package/build/agent-capabilities/runner.js +56 -0
- package/build/agent-capabilities/types.js +10 -0
- package/build/agent-launchers/claude.js +25 -17
- package/build/agent-launchers/cursor.js +65 -0
- package/build/agent-launchers/index.js +23 -8
- package/build/agent-registry.js +68 -0
- package/build/agents.generated.js +1 -1
- package/build/brainstorm-files.js +89 -0
- package/build/bridge-config.js +404 -0
- package/build/chain-orchestrator.js +247 -33
- package/build/command-catalog.js +376 -0
- package/build/commands.generated.js +10 -7
- package/build/credential-materialization.js +128 -0
- package/build/credential-store.js +232 -0
- package/build/decision-page-schema.js +39 -6
- package/build/decision-page-template.js +54 -18
- package/build/doctor.js +18 -2
- package/build/git-ignore-utils.js +63 -0
- package/build/index.js +1707 -557
- package/build/mcp-invoke.js +417 -0
- package/build/mcp-provisioning.js +342 -0
- package/build/mcp-registration-doctor.js +96 -0
- package/build/pipeline-orchestrator.js +9 -1
- package/build/pipelines.generated.js +5 -3
- package/build/schedule-run.js +440 -92
- package/build/schedule-store.js +41 -1
- package/build/scheduled-prompt.js +109 -0
- package/build/scheduler-backends/at-fallback.js +5 -10
- package/build/scheduler-backends/escaping.js +40 -10
- package/build/scheduler-backends/launchd.js +23 -14
- package/build/scheduler-backends/systemd-user.js +32 -19
- package/build/scheduler-backends/task-scheduler.js +8 -13
- package/build/start-tickets-prereqs.js +90 -1
- package/build/start-tickets.js +563 -42
- package/build/third-party-mcp-targets.js +75 -0
- package/build/version.generated.js +1 -1
- package/package.json +4 -3
- package/pipelines/full-automation.json +3 -1
- package/smoke-test/SMOKE-TEST.md +62 -17
package/build/start-tickets.js
CHANGED
|
@@ -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`
|
|
44
|
-
*
|
|
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
|
|
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
|
|
805
|
+
* (`/implement-ticket <KEY> --auto`) — used by full-automation chains.
|
|
793
806
|
*/
|
|
794
807
|
export function buildAgentPrompt(key, opts = {}) {
|
|
795
|
-
|
|
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
|
|
799
|
-
*
|
|
800
|
-
*
|
|
801
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1117
|
-
* platform formatting only — no
|
|
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
|
-
|
|
1144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1187
|
-
const
|
|
1188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|