@bridge_gpt/mcp-server 0.2.2 → 0.2.4

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 (113) hide show
  1. package/README.md +97 -15
  2. package/build/agent-config-credential-migration.js +272 -0
  3. package/build/agents.generated.js +1 -1
  4. package/build/chain-orchestrator.js +16 -1
  5. package/build/commands.generated.js +9 -7
  6. package/build/conductor/bridge-api-client.js +625 -0
  7. package/build/conductor/claude-hook.js +251 -0
  8. package/build/conductor/cli.js +1048 -0
  9. package/build/conductor/data-normalization.js +114 -0
  10. package/build/conductor/doctor.js +164 -0
  11. package/build/conductor/done-gate.js +325 -0
  12. package/build/conductor/epic-reconcile.js +139 -0
  13. package/build/conductor/epic-runtime.js +611 -0
  14. package/build/conductor/epic-state.js +125 -0
  15. package/build/conductor/errors.js +85 -0
  16. package/build/conductor/git-ci-types.js +129 -0
  17. package/build/conductor/git-hooks.js +218 -0
  18. package/build/conductor/git-inspection.js +185 -0
  19. package/build/conductor/git-producer.js +137 -0
  20. package/build/conductor/merge-ledger.js +198 -0
  21. package/build/conductor/paths.js +224 -0
  22. package/build/conductor/plan.js +77 -0
  23. package/build/conductor/pr-ci-producer.js +427 -0
  24. package/build/conductor/pr-discovery.js +135 -0
  25. package/build/conductor/producer-ledger.js +125 -0
  26. package/build/conductor/redaction.js +112 -0
  27. package/build/conductor/store.js +1156 -0
  28. package/build/conductor/supervisor-config.js +150 -0
  29. package/build/conductor/supervisor-escalation.js +244 -0
  30. package/build/conductor/supervisor-judgment-python.js +141 -0
  31. package/build/conductor/supervisor-judgment.js +215 -0
  32. package/build/conductor/supervisor-ledger.js +119 -0
  33. package/build/conductor/supervisor-merge.js +127 -0
  34. package/build/conductor/supervisor-message-relay.js +61 -0
  35. package/build/conductor/supervisor-notification.js +39 -0
  36. package/build/conductor/supervisor-runtime.js +351 -0
  37. package/build/conductor/supervisor-state.js +572 -0
  38. package/build/conductor/supervisor-types.js +16 -0
  39. package/build/conductor/taxonomy.js +58 -0
  40. package/build/conductor/tools.js +367 -0
  41. package/build/conductor/types.js +9 -0
  42. package/build/conductor-bin.js +21 -0
  43. package/build/conductor-claude-hook-bin.js +21 -0
  44. package/build/credential-store.js +175 -4
  45. package/build/credentials-cli.js +223 -0
  46. package/build/decision-page-schema.js +60 -0
  47. package/build/decision-page-template.js +262 -10
  48. package/build/doctor.js +5 -1
  49. package/build/index.js +554 -66
  50. package/build/pipeline-orchestrator.js +5 -1
  51. package/build/pipeline-utils.js +45 -5
  52. package/build/pipelines.generated.js +37 -9
  53. package/build/readme.generated.js +1 -1
  54. package/build/review-tickets.js +596 -0
  55. package/build/scheduled-prompt.js +16 -10
  56. package/build/start-tickets-conductor.js +496 -0
  57. package/build/start-tickets-prereqs.js +32 -23
  58. package/build/start-tickets-repo.js +49 -0
  59. package/build/start-tickets.js +682 -81
  60. package/build/version.generated.js +1 -1
  61. package/design-assets/favicon/android-chrome-192x192.png +0 -0
  62. package/design-assets/favicon/android-chrome-512x512.png +0 -0
  63. package/design-assets/favicon/apple-touch-icon.png +0 -0
  64. package/design-assets/favicon/favicon-16x16.png +0 -0
  65. package/design-assets/favicon/favicon-32x32.png +0 -0
  66. package/design-assets/favicon/favicon.ico +0 -0
  67. package/design-assets/favicon/site.webmanifest +1 -0
  68. package/design-assets/just-logo-rough-draft.png +0 -0
  69. package/package.json +17 -5
  70. package/pipelines/idea-to-ticket.json +5 -0
  71. package/pipelines/plan-epic.json +16 -1
  72. package/pipelines/review-ticket.json +2 -1
  73. package/public/css/main.min.css +2 -0
  74. package/public/css/main.min.css.map +1 -0
  75. package/public/fonts/OFL.txt +93 -0
  76. package/public/fonts/SourceSansPro-Black.ttf +0 -0
  77. package/public/fonts/SourceSansPro-BlackItalic.ttf +0 -0
  78. package/public/fonts/SourceSansPro-Bold.ttf +0 -0
  79. package/public/fonts/SourceSansPro-BoldItalic.ttf +0 -0
  80. package/public/fonts/SourceSansPro-ExtraLight.ttf +0 -0
  81. package/public/fonts/SourceSansPro-ExtraLightItalic.ttf +0 -0
  82. package/public/fonts/SourceSansPro-Italic.ttf +0 -0
  83. package/public/fonts/SourceSansPro-Light.ttf +0 -0
  84. package/public/fonts/SourceSansPro-LightItalic.ttf +0 -0
  85. package/public/fonts/SourceSansPro-Regular.ttf +0 -0
  86. package/public/fonts/SourceSansPro-SemiBold.ttf +0 -0
  87. package/public/fonts/SourceSansPro-SemiBoldItalic.ttf +0 -0
  88. package/public/img/bridge-logo-160x51.webp +0 -0
  89. package/public/img/bridge-logo-300x92.webp +0 -0
  90. package/public/img/favicon/android-chrome-192x192.png +0 -0
  91. package/public/img/favicon/android-chrome-512x512.png +0 -0
  92. package/public/img/favicon/apple-touch-icon.png +0 -0
  93. package/public/img/favicon/favicon-16x16.png +0 -0
  94. package/public/img/favicon/favicon-32x32.png +0 -0
  95. package/public/img/favicon/favicon.ico +0 -0
  96. package/public/img/favicon/site.webmanifest +1 -0
  97. package/public/img/installation/bitbucket/app-password-1.png +0 -0
  98. package/public/img/installation/bitbucket/app-password-2.png +0 -0
  99. package/public/img/installation/bitbucket/create-token-1.png +0 -0
  100. package/public/img/installation/bitbucket/create-token-2.png +0 -0
  101. package/public/img/installation/bitbucket/webhook-1.png +0 -0
  102. package/public/img/installation/github/github-review-webhook.png +0 -0
  103. package/public/img/installation/jira/credentials/api-key.png +0 -0
  104. package/public/img/installation/jira/webhook/create-rule.png +0 -0
  105. package/public/img/installation/jira/webhook/project-settings.png +0 -0
  106. package/public/img/installation/jira/webhook/rule-create-1.png +0 -0
  107. package/public/img/installation/jira/webhook/rule-create-2.png +0 -0
  108. package/public/img/installation/jira/webhook/rule-create-3.png +0 -0
  109. package/public/img/installation/pinecone/pinecone-api-key.png +0 -0
  110. package/public/img/installation/pinecone/pinecone-index.png +0 -0
  111. package/public/js/main.min.js +2 -0
  112. package/public/js/main.min.js.map +1 -0
  113. package/smoke-test/SMOKE-TEST.md +17 -9
@@ -58,8 +58,8 @@ import { readFile, writeFile, mkdir, stat } from "fs/promises";
58
58
  import os from "node:os";
59
59
  import path from "path";
60
60
  import { VERSION } from "./version.generated.js";
61
- import { resolveBapiCredentials } from "./credential-store.js";
62
- import { readBridgeConfig } from "./bridge-config.js";
61
+ import { resolveBapiCredentials, getPrimaryCredentialStorePath, } from "./credential-store.js";
62
+ import { resolveStartTicketsRepoName as resolveSharedStartTicketsRepoName, resolveRequiredStartTicketsRepoName, } from "./start-tickets-repo.js";
63
63
  import { provisionMcpRegistrationsForCreatedWorktrees, } from "./mcp-provisioning.js";
64
64
  // Per-OS prerequisite knowledge + low-level command probes live in the shared
65
65
  // prereqs module so `runPreflight` (enforce) and the read-only `doctor` (render)
@@ -67,6 +67,8 @@ import { provisionMcpRegistrationsForCreatedWorktrees, } from "./mcp-provisionin
67
67
  // module imports only TYPES back, so the runtime graph stays acyclic.
68
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";
69
69
  import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, resolveModelAlias, isValidModelAlias, isModelTier, } from "./agent-registry.js";
70
+ import { createStartTicketsConductorContext, provisionConductorHooksForRows, emitStartTicketsRunStarted, injectConductorEnvIntoShellCommand, buildSupervisorTabCommand, isSupervisorLaunchEnabled, supervisorSpawnKey, } from "./start-tickets-conductor.js";
71
+ import { transitionEpicDispatch, resolveConductorBridgeApiAccess, } from "./conductor/bridge-api-client.js";
70
72
  // Re-export the shared prereq surface (constants, platform helpers, command
71
73
  // probes) so existing import sites that read them from "./start-tickets.js"
72
74
  // keep working unchanged.
@@ -108,6 +110,16 @@ export function getStartTicketsUsage() {
108
110
  "Environment:",
109
111
  ` ${WORKTRUNK_BINARY_OVERRIDE_ENV} Override the Worktrunk executable name/path for nonstandard installs`,
110
112
  ` ${TMUX_SESSION_OVERRIDE_ENV} Override the tmux session-name prefix on Linux (default: ${DEFAULT_TMUX_SESSION_PREFIX})`,
113
+ " BAPI_CONDUCTOR_GATE_NAME Conductor gate name for this run (default: implement-ticket)",
114
+ " BAPI_CONDUCTOR_SUPERVISOR_MODE Conductor supervisor mode (default: auto when --auto, else interactive)",
115
+ " BAPI_CONDUCTOR_ENABLE_PRE_TOOL_USE Set 1/true to also register a PreToolUse conductor hook",
116
+ "",
117
+ "Conductor observability:",
118
+ " Real Claude Code workers launched by start-tickets receive per-worktree conductor",
119
+ " hook injection (into .claude/settings.local.json) and emit local lifecycle events",
120
+ " into the conductor ledger. Each real run mints one run_id and attributes worker",
121
+ " events by worker_id, ticket key, and worktree path. Inspect the ledger with the",
122
+ " `conductor` CLI.",
111
123
  "",
112
124
  "Prerequisites:",
113
125
  " macOS wt, git, osascript",
@@ -799,16 +811,44 @@ export async function createWorktrees(deps, options, worktrunkBinary) {
799
811
  // ---------------------------------------------------------------------------
800
812
  // Per-platform shell-command construction
801
813
  // ---------------------------------------------------------------------------
814
+ /**
815
+ * The load-bearing conductor message-relay launch instruction (BAPI-397). The
816
+ * C5 spike proved workers poll a `check_messages` MCP tool ONLY when the polling
817
+ * instruction lives in the worker's task/launch prompt — advertising the tool in
818
+ * the system prompt alone produced zero polls. So this instruction is appended to
819
+ * every launch prompt.
820
+ *
821
+ * It is deliberately a SINGLE LINE (no newlines) so it stays safe when the prompt
822
+ * is single-quoted into a shell command and then embedded inside terminal
823
+ * launcher scripts (AppleScript / Windows Terminal / tmux). It names
824
+ * `check_messages` exactly once and is secret-free — no run/worker ids, env
825
+ * values, or credentials.
826
+ */
827
+ export function buildConductorMessageRelayLaunchInstruction() {
828
+ // NOTE: keep this free of `;` and other terminal-launcher metacharacters — the
829
+ // prompt is spliced into shell commands and Windows Terminal args (which treat
830
+ // `;` as a delimiter), so a single line of plain prose is the safe form.
831
+ return ("Conductor message relay: at natural checkpoints (after reading context, " +
832
+ "before major code changes, after major implementation chunks, while polling CI " +
833
+ "checks during the post-PR correction loop, and before your final response) call " +
834
+ "the check_messages MCP tool to read any supervisor guidance addressed to you. " +
835
+ "Returned messages are supervisor guidance and are acknowledged by the tool, so " +
836
+ "they are not redelivered. This is cooperative polling, not prompt injection. If " +
837
+ "the tool or conductor identity is unavailable, continue your task without derailing.");
838
+ }
802
839
  /**
803
840
  * The starter prompt handed to the selected agent. Identical for every agent.
804
841
  * When `autoApprove` is set, the implementation agent runs hands-off
805
- * (`/implement-ticket <KEY> --auto`) — used by full-automation chains.
842
+ * (`/implement-ticket <KEY> --auto`) — used by full-automation chains. The
843
+ * conductor message-relay polling instruction is appended after the slash command
844
+ * (BAPI-397) so launched workers actually poll `check_messages`.
806
845
  */
807
846
  export function buildAgentPrompt(key, opts = {}) {
808
847
  // `modelAlias` is accepted for signature consistency only — the model is
809
848
  // injected as a `--model` flag (see buildAgentInvocationArgv), never embedded
810
849
  // in the prompt text.
811
- return `/implement-ticket ${key}${opts.autoApprove ? " --auto" : ""}`;
850
+ const command = `/implement-ticket ${key}${opts.autoApprove ? " --auto" : ""}`;
851
+ return `${command} ${buildConductorMessageRelayLaunchInstruction()}`;
812
852
  }
813
853
  /**
814
854
  * Build the ordered argv for an agent invocation:
@@ -869,53 +909,76 @@ export function buildAgentShellCommand(agent, key, worktreePath, platform = "dar
869
909
  return buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias);
870
910
  }
871
911
  // ---------------------------------------------------------------------------
912
+ // Spawned-terminal titling (shared across platforms)
913
+ // ---------------------------------------------------------------------------
914
+ /**
915
+ * Human-facing title applied to each spawned terminal so users can tell which
916
+ * ticket a tab/window/session is implementing — e.g. `BAPI-200 Implementation`.
917
+ * `key` is the full ticket key (already `PROJ-NUM`).
918
+ */
919
+ export function terminalTitleForTicket(key) {
920
+ return `${key} Implementation`;
921
+ }
922
+ // ---------------------------------------------------------------------------
872
923
  // macOS terminal spawning (behind the injected boundary)
873
924
  // ---------------------------------------------------------------------------
874
- /** Generate AppleScript that runs `shellCommand` in a Terminal.app tab. */
875
- export function buildTerminalAppleScript(shellCommand) {
925
+ /**
926
+ * Generate AppleScript that runs `shellCommand` in a Terminal.app tab and sets
927
+ * its custom title. `do script` returns the spawned tab in both the new-window
928
+ * and new-tab paths, so we capture it and set `custom title` on it.
929
+ */
930
+ export function buildTerminalAppleScript(shellCommand, title) {
876
931
  const esc = applescriptDquoteInner(shellCommand);
932
+ const titleEsc = applescriptDquoteInner(title);
877
933
  return [
878
934
  'tell application "Terminal"',
879
935
  " activate",
880
936
  " if (count of windows) is 0 then",
881
- ` do script "${esc}"`,
937
+ ` set spawnedTab to do script "${esc}"`,
882
938
  " else",
883
939
  ' tell application "System Events" to keystroke "t" using command down',
884
940
  " delay 0.2",
885
- ` do script "${esc}" in selected tab of front window`,
941
+ ` set spawnedTab to do script "${esc}" in selected tab of front window`,
886
942
  " end if",
943
+ ` set custom title of spawnedTab to "${titleEsc}"`,
887
944
  "end tell",
888
945
  ].join("\n");
889
946
  }
890
- /** Generate AppleScript that runs `shellCommand` in an iTerm2 tab. */
891
- export function buildITermAppleScript(shellCommand) {
947
+ /**
948
+ * Generate AppleScript that runs `shellCommand` in an iTerm2 tab. We set the
949
+ * session `name` (which drives the tab title) so the label sticks rather than
950
+ * being overwritten by the running agent's program title.
951
+ */
952
+ export function buildITermAppleScript(shellCommand, title) {
892
953
  const esc = applescriptDquoteInner(shellCommand);
954
+ const titleEsc = applescriptDquoteInner(title);
893
955
  return [
894
956
  'tell application "iTerm"',
895
957
  " activate",
896
958
  " if (count of windows) = 0 then",
897
- " set newWindow to (create window with default profile)",
898
- ` tell current session of newWindow to write text "${esc}"`,
959
+ " set spawnedSession to current session of (create window with default profile)",
899
960
  " else",
900
- " tell current window",
901
- " set newTab to (create tab with default profile)",
902
- ` tell current session of newTab to write text "${esc}"`,
903
- " end tell",
961
+ " tell current window to set spawnedSession to (current session of (create tab with default profile))",
904
962
  " end if",
963
+ " tell spawnedSession",
964
+ ` set name to "${titleEsc}"`,
965
+ ` write text "${esc}"`,
966
+ " end tell",
905
967
  "end tell",
906
968
  ].join("\n");
907
969
  }
908
970
  /**
909
971
  * Spawn a single macOS terminal tab running `shellCommand`. Selects the
910
972
  * AppleScript builder by terminal choice and runs it via `osascript -e`. The
911
- * optional `context` is accepted to satisfy the shared spawner signature but is
912
- * unused on macOS. Returns a structured failure for expected spawn errors
913
- * (never throws).
973
+ * `context.key` is used to title the tab `<KEY> Implementation`; a missing
974
+ * context falls back to an untitled-but-functional tab. Returns a structured
975
+ * failure for expected spawn errors (never throws).
914
976
  */
915
- export async function spawnMacOSTerminalTab(deps, terminal, shellCommand, _context) {
977
+ export async function spawnMacOSTerminalTab(deps, terminal, shellCommand, context) {
978
+ const title = terminalTitleForTicket(context?.key ?? "");
916
979
  const script = terminal === "iterm"
917
- ? buildITermAppleScript(shellCommand)
918
- : buildTerminalAppleScript(shellCommand);
980
+ ? buildITermAppleScript(shellCommand, title)
981
+ : buildTerminalAppleScript(shellCommand, title);
919
982
  const result = await deps.runCommand("osascript", ["-e", script]);
920
983
  if (commandSucceeded(result))
921
984
  return { ok: true };
@@ -940,9 +1003,23 @@ export async function spawnMacOSTerminalTab(deps, terminal, shellCommand, _conte
940
1003
  * `Start-Process` fallback below does its own quoting via `-ArgumentList` and is
941
1004
  * unaffected.)
942
1005
  */
943
- export function buildWindowsTerminalArgs(worktreePath, shellCommand) {
1006
+ export function buildWindowsTerminalArgs(worktreePath, shellCommand, title) {
944
1007
  const wtEscapedCommand = shellCommand.replace(/;/g, "\\;");
945
- return ["new-tab", "-d", worktreePath, "powershell.exe", "-NoExit", "-Command", wtEscapedCommand];
1008
+ // `--title` labels the tab `<KEY> Implementation`; `--suppressApplicationTitle`
1009
+ // stops the running agent from overwriting that title. Both are passed as
1010
+ // discrete argv items, so the space in the title needs no extra quoting.
1011
+ return [
1012
+ "new-tab",
1013
+ "--title",
1014
+ title,
1015
+ "--suppressApplicationTitle",
1016
+ "-d",
1017
+ worktreePath,
1018
+ "powershell.exe",
1019
+ "-NoExit",
1020
+ "-Command",
1021
+ wtEscapedCommand,
1022
+ ];
946
1023
  }
947
1024
  /**
948
1025
  * Build the PowerShell `Start-Process` command used as the no-Windows-Terminal
@@ -950,8 +1027,12 @@ export function buildWindowsTerminalArgs(worktreePath, shellCommand) {
950
1027
  * `powershell.exe -Command` invoked through `execFile` cannot). Nested quoting
951
1028
  * uses PowerShell single-quote escaping.
952
1029
  */
953
- export function buildPowerShellFallbackStartProcessCommand(worktreePath, shellCommand) {
954
- const argumentList = `@('-NoExit', '-Command', ${powershellSquote(shellCommand)})`;
1030
+ export function buildPowerShellFallbackStartProcessCommand(worktreePath, shellCommand, title) {
1031
+ // Set the new window's title (`<KEY> Implementation`) before running the agent
1032
+ // so the user can tell the windows apart; `Start-Process` opens its own window
1033
+ // so we cannot use wt's `--title`.
1034
+ const titledCommand = `$host.UI.RawUI.WindowTitle = ${powershellSquote(title)}; ${shellCommand}`;
1035
+ const argumentList = `@('-NoExit', '-Command', ${powershellSquote(titledCommand)})`;
955
1036
  return (`Start-Process -FilePath 'powershell.exe' ` +
956
1037
  `-WorkingDirectory ${powershellSquote(worktreePath)} ` +
957
1038
  `-ArgumentList ${argumentList}`);
@@ -970,8 +1051,9 @@ export async function spawnWindowsTerminalTab(deps, _terminal, shellCommand, con
970
1051
  error: "Windows spawner requires a worktreePath context to open a tab.",
971
1052
  };
972
1053
  }
1054
+ const title = terminalTitleForTicket(context?.key ?? "");
973
1055
  if (await isCommandOnPath(deps, WINDOWS_TERMINAL_COMMAND)) {
974
- const args = buildWindowsTerminalArgs(worktreePath, shellCommand);
1056
+ const args = buildWindowsTerminalArgs(worktreePath, shellCommand, title);
975
1057
  const result = await deps.runCommand(WINDOWS_TERMINAL_COMMAND, args);
976
1058
  if (commandSucceeded(result))
977
1059
  return { ok: true };
@@ -988,7 +1070,7 @@ export async function spawnWindowsTerminalTab(deps, _terminal, shellCommand, con
988
1070
  error: "Windows Terminal (wt.exe) or PowerShell is required to open a tab, but neither was found on PATH.",
989
1071
  };
990
1072
  }
991
- const fallback = buildPowerShellFallbackStartProcessCommand(worktreePath, shellCommand);
1073
+ const fallback = buildPowerShellFallbackStartProcessCommand(worktreePath, shellCommand, title);
992
1074
  const result = await deps.runCommand(powershell, [
993
1075
  "-NoProfile",
994
1076
  "-ExecutionPolicy",
@@ -1015,9 +1097,15 @@ export function sanitizeTmuxName(value) {
1015
1097
  .replace(/-+$/, "");
1016
1098
  return cleaned.length > 0 ? cleaned : "ticket";
1017
1099
  }
1018
- /** Safe tmux window name derived from the ticket key. */
1100
+ /**
1101
+ * tmux WINDOW name shown to the user, e.g. `BAPI-200 Implementation`. The key
1102
+ * portion is sanitized (drops `/`, `:`, etc.) but the human-facing
1103
+ * ` Implementation` suffix keeps its space — tmux window names may contain
1104
+ * spaces. The SESSION name stays space-free (see `tmuxSessionNameForTicket`) so
1105
+ * `tmux attach -t <session>` remains easy to type.
1106
+ */
1019
1107
  export function tmuxWindowNameForTicket(key) {
1020
- return sanitizeTmuxName(key);
1108
+ return terminalTitleForTicket(sanitizeTmuxName(key));
1021
1109
  }
1022
1110
  /** Resolve the tmux session-name prefix (env override, else the default). */
1023
1111
  export function tmuxSessionPrefix(deps) {
@@ -1111,7 +1199,11 @@ export async function spawnTabsForCreatedWorktrees(deps, rows, terminal, buildSh
1111
1199
  out.push(row);
1112
1200
  continue;
1113
1201
  }
1114
- const shellCommand = buildShellCommand(row.key, row.path, row.modelAlias ?? null);
1202
+ const baseShellCommand = buildShellCommand(row.key, row.path, row.modelAlias ?? null);
1203
+ // Scope the row's conductor identity env to this terminal/tab/session only
1204
+ // (BAPI-394). No-op when the row carries no conductorEnv (dry-run, non-Claude
1205
+ // agents, or conductor disabled). Never mutates process/global env.
1206
+ const shellCommand = injectConductorEnvIntoShellCommand(deps.platform, baseShellCommand, row.conductorEnv);
1115
1207
  const result = await deps.spawnTerminalTab(deps, terminal, shellCommand, {
1116
1208
  key: row.key,
1117
1209
  worktreePath: row.path,
@@ -1195,10 +1287,24 @@ export function buildDryRunDetailLines(agent, key, branch, platform = process.pl
1195
1287
  */
1196
1288
  export function formatSummaryReport(rows) {
1197
1289
  const lines = ["Summary:"];
1290
+ // Surface the single conductor run id near the top (first line stays
1291
+ // "Summary:" so existing parsers are unaffected). All spawnable rows carry the
1292
+ // same runId, so the first one present is canonical.
1293
+ const runId = rows.find((r) => r.runId)?.runId;
1294
+ if (runId) {
1295
+ lines.push(`run_id=${runId}`);
1296
+ }
1297
+ // BAPI-396: surface the supervisor peer-tab launch status once (run-level).
1298
+ const supervisorStatus = rows.find((r) => r.supervisorStatus)?.supervisorStatus;
1299
+ if (supervisorStatus) {
1300
+ lines.push(`supervisor=${supervisorStatus}`);
1301
+ }
1198
1302
  for (const row of rows) {
1199
1303
  let line = `${row.key} branch=${row.branch} status=${row.status}`;
1200
1304
  if (row.path)
1201
1305
  line += ` path=${row.path}`;
1306
+ if (row.workerId)
1307
+ line += ` worker_id=${row.workerId}`;
1202
1308
  lines.push(line);
1203
1309
  }
1204
1310
  // Warnings section: create/spawn-failed row errors AND any non-fatal
@@ -1267,6 +1373,78 @@ export function buildMcpProvisioningDeps(deps) {
1267
1373
  export async function materializeFileCredentialsForCreatedWorktrees(rows, _deps) {
1268
1374
  return rows;
1269
1375
  }
1376
+ /**
1377
+ * Map a diagnostic kind to its observability policy. Expected setup gaps
1378
+ * (missing/unreadable/malformed credentials, unauthorized) and transient
1379
+ * degradation (network, config/tier unavailable, no-tier) are stderr-only so
1380
+ * routine setup friction never generates telemetry noise. Genuine server `5xx`
1381
+ * and unexpected internal HTTP failures may additionally be reported to Sentry.
1382
+ */
1383
+ export function diagnosticReportingPolicyForKind(kind) {
1384
+ switch (kind) {
1385
+ case "server":
1386
+ case "http":
1387
+ return "stderr-and-sentry";
1388
+ default:
1389
+ return "stderr-only";
1390
+ }
1391
+ }
1392
+ /** Build a secret-free diagnostic, deriving the reporting policy from the kind. */
1393
+ export function makeModelRoutingDiagnostic(fields) {
1394
+ return { ...fields, reportingPolicy: diagnosticReportingPolicyForKind(fields.kind) };
1395
+ }
1396
+ /**
1397
+ * Turn a diagnostic into exactly ONE actionable, stderr-safe line. Never
1398
+ * includes secrets, headers, tokens, or raw response bodies. Every degraded
1399
+ * state states that ticket spawning continues fail-open (assume a hard ticket,
1400
+ * default to premium/Opus when the agent supports it).
1401
+ */
1402
+ export function formatModelRoutingDiagnosticLine(d) {
1403
+ const repo = d.repoName ?? "<unknown>";
1404
+ const target = d.storeTarget ?? (d.repoName ? `bapi:${d.repoName}` : "bapi:<repo>");
1405
+ const storePath = d.storePath ?? "~/.config/bridge/credentials.json";
1406
+ const failOpen = "Assuming hard ticket; defaulting to premium/Opus when available.";
1407
+ const ticketSuffix = d.ticketKey ? ` for ${d.ticketKey}` : "";
1408
+ const statusSuffix = typeof d.status === "number" ? ` (HTTP ${d.status})` : "";
1409
+ switch (d.kind) {
1410
+ case "repo-missing":
1411
+ return ("model routing skipped: could not resolve repo name (set BAPI_REPO_NAME or add a " +
1412
+ `valid .bridge/config). ${failOpen}`);
1413
+ case "credential-not-found":
1414
+ case "credential-missing-key":
1415
+ return (`model routing skipped: no BAPI_API_KEY for repo ${repo}. Run /install-bridge again, ` +
1416
+ `or add BAPI_API_KEY under ${target} in ${storePath}. ${failOpen}`);
1417
+ case "credential-read-error":
1418
+ return (`model routing skipped: could not read the credential store at ${storePath} for ${target}. ` +
1419
+ `Fix file permissions or rerun /install-bridge. ${failOpen}`);
1420
+ case "credential-parse-error":
1421
+ return (`model routing skipped: the credential store at ${storePath} is malformed (cannot parse ` +
1422
+ `${target}). Repair it or rerun /install-bridge. ${failOpen}`);
1423
+ case "unauthorized":
1424
+ return (`model routing degraded: a BAPI_API_KEY for ${target} was found but rejected by Bridge ` +
1425
+ `API${statusSuffix}. Rerun /install-bridge or rotate the stored ${target} key. ${failOpen}`);
1426
+ case "network":
1427
+ return ("model routing degraded: could not reach Bridge API (network error or timeout). Ticket " +
1428
+ `spawning continues fail-open. ${failOpen}`);
1429
+ case "server":
1430
+ return (`model routing degraded: Bridge API returned a server error${statusSuffix}. Ticket ` +
1431
+ `spawning continues fail-open. ${failOpen}`);
1432
+ case "http":
1433
+ return (`model routing degraded: unexpected Bridge API response${statusSuffix}. Ticket spawning ` +
1434
+ `continues fail-open. ${failOpen}`);
1435
+ case "config-unavailable":
1436
+ return ("model routing degraded: could not fetch routing config from Bridge API. Ticket spawning " +
1437
+ `continues fail-open. ${failOpen}`);
1438
+ case "tier-fetch-unavailable":
1439
+ return (`model routing degraded: could not fetch the model tier${ticketSuffix} from Bridge API. ` +
1440
+ `Ticket spawning continues fail-open. ${failOpen}`);
1441
+ case "no-tier":
1442
+ return (`model routing: Bridge API returned no usable tier${ticketSuffix}. Ticket spawning ` +
1443
+ `continues fail-open. ${failOpen}`);
1444
+ default:
1445
+ return `model routing degraded: ${d.message} ${failOpen}`;
1446
+ }
1447
+ }
1270
1448
  // ---------------------------------------------------------------------------
1271
1449
  // BAPI-365: difficulty-based model routing (fail-open at the spawn boundary)
1272
1450
  // ---------------------------------------------------------------------------
@@ -1284,25 +1462,17 @@ function startTicketsGetHeaders(access) {
1284
1462
  }
1285
1463
  /**
1286
1464
  * Resolve the repo name for routing: prefer `BAPI_REPO_NAME`, then `.bridge/config`.
1287
- * Returns `null` when neither is available.
1465
+ * Returns `null` when neither is available. Thin wrapper over the shared
1466
+ * {@link resolveSharedStartTicketsRepoName} helper (see `start-tickets-repo.ts`)
1467
+ * so `start-tickets`, doctor, migration, and install persistence all share one
1468
+ * repo-identity implementation and the `bapi:<repo>` target can never drift.
1288
1469
  */
1289
1470
  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;
1471
+ return resolveSharedStartTicketsRepoName({
1472
+ env: deps.env,
1473
+ cwd: deps.cwd,
1474
+ readFile: (filePath) => readFile(filePath, "utf-8"),
1475
+ });
1306
1476
  }
1307
1477
  /**
1308
1478
  * Resolve Bridge API access (repo name + API key + base URL). Returns a
@@ -1310,13 +1480,26 @@ export async function resolveStartTicketsRepoName(deps) {
1310
1480
  * caller can degrade to the agent default model.
1311
1481
  */
1312
1482
  export async function resolveStartTicketsBridgeApiAccess(deps) {
1313
- const repoName = await resolveStartTicketsRepoName(deps);
1314
- if (!repoName) {
1483
+ const repoResult = await resolveRequiredStartTicketsRepoName({
1484
+ env: deps.env,
1485
+ cwd: deps.cwd,
1486
+ readFile: (p) => readFile(p, "utf-8"),
1487
+ });
1488
+ if (!repoResult.ok) {
1315
1489
  return {
1316
1490
  ok: false,
1317
1491
  warning: "model routing: could not resolve repo name (set BAPI_REPO_NAME or .bridge/config); using agent default model",
1492
+ diagnostic: makeModelRoutingDiagnostic({
1493
+ kind: "repo-missing",
1494
+ message: "could not resolve repo name for model routing",
1495
+ }),
1318
1496
  };
1319
1497
  }
1498
+ const repoName = repoResult.repoName;
1499
+ // Logical store target + path for diagnostics and remediation text only — the
1500
+ // resolved key value is NEVER recorded in any diagnostic field.
1501
+ const storeTarget = `bapi:${repoName}`;
1502
+ const storePath = getPrimaryCredentialStorePath({ env: deps.env, homedir: os.homedir });
1320
1503
  let credResult;
1321
1504
  try {
1322
1505
  credResult = await resolveBapiCredentials(repoName, {
@@ -1328,10 +1511,39 @@ export async function resolveStartTicketsBridgeApiAccess(deps) {
1328
1511
  });
1329
1512
  }
1330
1513
  catch {
1331
- return { ok: false, warning: "model routing: failed to resolve Bridge API credentials; using agent default model" };
1514
+ return {
1515
+ ok: false,
1516
+ warning: "model routing: failed to resolve Bridge API credentials; using agent default model",
1517
+ diagnostic: makeModelRoutingDiagnostic({
1518
+ kind: "credential-read-error",
1519
+ repoName,
1520
+ storeTarget,
1521
+ storePath,
1522
+ message: "failed to resolve Bridge API credentials",
1523
+ }),
1524
+ };
1332
1525
  }
1333
1526
  if (!credResult.ok) {
1334
- return { ok: false, warning: "model routing: Bridge API credentials unavailable; using agent default model" };
1527
+ // Preserve the resolver's distinct failure kinds rather than collapsing them
1528
+ // into one generic "unavailable" warning.
1529
+ const kind = credResult.kind === "not-found"
1530
+ ? "credential-not-found"
1531
+ : credResult.kind === "read-error"
1532
+ ? "credential-read-error"
1533
+ : credResult.kind === "parse-error"
1534
+ ? "credential-parse-error"
1535
+ : "credential-missing-key";
1536
+ return {
1537
+ ok: false,
1538
+ warning: "model routing: Bridge API credentials unavailable; using agent default model",
1539
+ diagnostic: makeModelRoutingDiagnostic({
1540
+ kind,
1541
+ repoName,
1542
+ storeTarget,
1543
+ storePath,
1544
+ message: "Bridge API credentials unavailable",
1545
+ }),
1546
+ };
1335
1547
  }
1336
1548
  const baseUrlRaw = deps.env.BAPI_BASE_URL;
1337
1549
  const baseUrl = typeof baseUrlRaw === "string" && baseUrlRaw.trim().length > 0
@@ -1352,15 +1564,67 @@ export function buildStartTicketsJiraUrl(baseUrl, apiPath, params = {}) {
1352
1564
  * GET JSON with an `AbortController` timeout. Throws a generic error (no secret
1353
1565
  * material) on a non-2xx response; always clears the timeout.
1354
1566
  */
1567
+ /**
1568
+ * Secret-free HTTP error for the CLI's Bridge API calls. Carries a coarse `kind`
1569
+ * and optional `status` so callers can classify the routing degradation. The
1570
+ * message is generic and NEVER includes request headers, the API key, or the
1571
+ * response body.
1572
+ */
1573
+ export class StartTicketsHttpError extends Error {
1574
+ kind;
1575
+ status;
1576
+ constructor(kind, status) {
1577
+ super(`Bridge API request failed (${kind}${typeof status === "number" ? `, status ${status}` : ""})`);
1578
+ this.name = "StartTicketsHttpError";
1579
+ this.kind = kind;
1580
+ this.status = status;
1581
+ }
1582
+ }
1583
+ /**
1584
+ * Build a routing diagnostic from a caught fetch error. A
1585
+ * {@link StartTicketsHttpError} maps to its `kind` (unauthorized/server/network/
1586
+ * http); any other error uses `fallbackKind` (e.g. config/tier unavailable).
1587
+ */
1588
+ function diagnosticFromFetchError(err, opts) {
1589
+ const base = {
1590
+ repoName: opts.repoName,
1591
+ storeTarget: opts.repoName ? `bapi:${opts.repoName}` : undefined,
1592
+ ticketKey: opts.ticketKey,
1593
+ message: opts.message,
1594
+ };
1595
+ if (err instanceof StartTicketsHttpError) {
1596
+ return makeModelRoutingDiagnostic({ ...base, kind: err.kind, status: err.status });
1597
+ }
1598
+ return makeModelRoutingDiagnostic({ ...base, kind: opts.fallbackKind });
1599
+ }
1355
1600
  export async function fetchJsonWithTimeout(url, headers, timeoutMs) {
1356
1601
  const controller = new AbortController();
1357
1602
  const timer = setTimeout(() => controller.abort(), timeoutMs);
1358
1603
  try {
1359
- const resp = await fetch(url, { headers, signal: controller.signal });
1604
+ let resp;
1605
+ try {
1606
+ resp = await fetch(url, { headers, signal: controller.signal });
1607
+ }
1608
+ catch {
1609
+ // Aborts (timeout), DNS/connection failures, and other fetch exceptions.
1610
+ throw new StartTicketsHttpError("network");
1611
+ }
1360
1612
  if (!resp.ok) {
1361
- throw new Error(`request failed with status ${resp.status}`);
1613
+ if (resp.status === 401 || resp.status === 403) {
1614
+ throw new StartTicketsHttpError("unauthorized", resp.status);
1615
+ }
1616
+ if (resp.status >= 500) {
1617
+ throw new StartTicketsHttpError("server", resp.status);
1618
+ }
1619
+ throw new StartTicketsHttpError("http", resp.status);
1620
+ }
1621
+ try {
1622
+ return await resp.json();
1623
+ }
1624
+ catch {
1625
+ // Body read/parse failure (often an abort mid-read) — treat as network.
1626
+ throw new StartTicketsHttpError("network");
1362
1627
  }
1363
- return await resp.json();
1364
1628
  }
1365
1629
  finally {
1366
1630
  clearTimeout(timer);
@@ -1397,7 +1661,8 @@ export async function fetchStartTicketsConfigField(access, fieldName) {
1397
1661
  /**
1398
1662
  * Fetch the repo's routing config once. A missing/null enable value defaults to
1399
1663
  * enabled (the feature ships ON); an explicit boolean `false` disables routing.
1400
- * Any fetch failure returns a structured failure so the caller omits `--model`.
1664
+ * Any fetch failure returns a structured failure (with a classified diagnostic)
1665
+ * so the caller fails open to premium/Opus.
1401
1666
  */
1402
1667
  export async function fetchDifficultyModelRoutingConfig(access) {
1403
1668
  try {
@@ -1406,13 +1671,22 @@ export async function fetchDifficultyModelRoutingConfig(access) {
1406
1671
  const enabled = enabledValue === false ? false : true;
1407
1672
  return { ok: true, config: { enabled, overrides: normalizeDifficultyModelTierOverrides(overridesValue) } };
1408
1673
  }
1409
- catch {
1410
- return { ok: false, warning: "model routing: failed to fetch routing config; using agent default model" };
1674
+ catch (err) {
1675
+ return {
1676
+ ok: false,
1677
+ warning: "model routing: failed to fetch routing config; using agent default model",
1678
+ diagnostic: diagnosticFromFetchError(err, {
1679
+ repoName: access.repoName,
1680
+ fallbackKind: "config-unavailable",
1681
+ message: "failed to fetch routing config",
1682
+ }),
1683
+ };
1411
1684
  }
1412
1685
  }
1413
1686
  /**
1414
1687
  * Fetch the coarse tier for one ticket with a bounded timeout. Never throws to
1415
- * the batch orchestrator: failures/timeouts return a structured warning.
1688
+ * the batch orchestrator: failures/timeouts return a structured warning plus a
1689
+ * classified diagnostic that preserves the ticket key in metadata.
1416
1690
  */
1417
1691
  export async function fetchTicketModelTierForStartTickets(access, ticket) {
1418
1692
  try {
@@ -1426,8 +1700,17 @@ export async function fetchTicketModelTierForStartTickets(access, ticket) {
1426
1700
  : "fallback";
1427
1701
  return { ok: true, value: { difficulty, tier, source } };
1428
1702
  }
1429
- catch {
1430
- return { ok: false, warning: `model routing: tier lookup failed for ${ticket}; using agent default model` };
1703
+ catch (err) {
1704
+ return {
1705
+ ok: false,
1706
+ warning: `model routing: tier lookup failed for ${ticket}; using agent default model`,
1707
+ diagnostic: diagnosticFromFetchError(err, {
1708
+ repoName: access.repoName,
1709
+ ticketKey: ticket,
1710
+ fallbackKind: "tier-fetch-unavailable",
1711
+ message: `tier lookup failed for ${ticket}`,
1712
+ }),
1713
+ };
1431
1714
  }
1432
1715
  }
1433
1716
  /**
@@ -1509,8 +1792,12 @@ export async function validateResolvedModelAliasForAgent(deps, agent, candidate)
1509
1792
  }
1510
1793
  return { ok: true, alias: candidate };
1511
1794
  }
1512
- /** Return a copy of `row` with default (no-routing) metadata and a reason. */
1513
- export function applyDefaultModelRoutingMetadata(row, reason) {
1795
+ /**
1796
+ * Return a copy of `row` with default (no-routing) metadata and a reason. An
1797
+ * optional `diagnostic` is stamped onto the row so the CLI can later emit one
1798
+ * de-duplicated invocation-level routing line.
1799
+ */
1800
+ export function applyDefaultModelRoutingMetadata(row, reason, diagnostic) {
1514
1801
  return {
1515
1802
  ...row,
1516
1803
  difficulty: row.difficulty ?? null,
@@ -1518,8 +1805,53 @@ export function applyDefaultModelRoutingMetadata(row, reason) {
1518
1805
  modelAlias: null,
1519
1806
  modelRoutingSource: row.modelRoutingSource ?? "default",
1520
1807
  modelRoutingReason: reason,
1808
+ ...(diagnostic ? { modelRoutingDiagnostic: diagnostic } : {}),
1521
1809
  };
1522
1810
  }
1811
+ /**
1812
+ * Resolve + validate the premium (Opus) alias ONCE and return a reusable applier.
1813
+ *
1814
+ * The premium alias is identical for every row in a batch, and validating it for
1815
+ * `cursor-agent` spawns a `cursor-agent --list-models` probe — so resolving per
1816
+ * row would fire N identical subprocess probes for a batch of N tickets. Hoisting
1817
+ * the resolve+validate here means at most one probe regardless of batch size
1818
+ * (claude's alias is static and never probes either way).
1819
+ *
1820
+ * The premium fallback deliberately reverses the older "omit --model" fail-safe:
1821
+ * an unknown/hard ticket should run on the strongest model rather than silently
1822
+ * downgrade. If the premium alias cannot be resolved/validated for this agent
1823
+ * (e.g. it doesn't support --model, or verification fails) the applier falls back
1824
+ * to the true no-routing default so we never emit an invalid --model.
1825
+ */
1826
+ async function makePremiumFallbackApplier(deps, agent, overrides) {
1827
+ const alias = resolveModelAlias(agent, "premium", overrides);
1828
+ const validation = alias ? await validateResolvedModelAliasForAgent(deps, agent, alias) : null;
1829
+ return (row, warning, diagnostic) => {
1830
+ if (validation && validation.ok) {
1831
+ const reason = `${warning}; defaulting to premium/Opus (model=${validation.alias})`;
1832
+ return appendSummaryRowWarning({
1833
+ ...row,
1834
+ difficulty: row.difficulty ?? null,
1835
+ modelTier: "premium",
1836
+ modelAlias: validation.alias,
1837
+ modelRoutingSource: "fallback",
1838
+ modelRoutingReason: reason,
1839
+ ...(diagnostic ? { modelRoutingDiagnostic: diagnostic } : {}),
1840
+ }, warning);
1841
+ }
1842
+ // Premium alias unavailable for this agent — fall back to agent default.
1843
+ return appendSummaryRowWarning(applyDefaultModelRoutingMetadata(row, warning, diagnostic), warning);
1844
+ };
1845
+ }
1846
+ /**
1847
+ * Apply the premium fallback to every eligible row (used by batch error
1848
+ * branches). An optional shared `diagnostic` is stamped onto every eligible row
1849
+ * receiving the fallback so the CLI can de-duplicate it to one invocation line.
1850
+ */
1851
+ async function applyPremiumFallbackToEligibleRows(deps, rows, isEligible, agent, overrides, warning, diagnostic) {
1852
+ const apply = await makePremiumFallbackApplier(deps, agent, overrides);
1853
+ return rows.map((r) => (isEligible(r) ? apply(r, warning, diagnostic) : r));
1854
+ }
1523
1855
  /** Real spawn path: a worktree was created and has a path. */
1524
1856
  const isCreatedRoutingEligible = (r) => r.status === "created" && !!r.path;
1525
1857
  /** Dry-run path: rows have no worktree path yet but are eligible for a preview. */
@@ -1541,16 +1873,15 @@ async function resolveModelRoutingForEligible(deps, rows, options, agent, isElig
1541
1873
  }
1542
1874
  const accessResult = await resolveStartTicketsBridgeApiAccess(deps);
1543
1875
  if (!accessResult.ok) {
1544
- return rows.map((r) => isEligible(r)
1545
- ? appendSummaryRowWarning(applyDefaultModelRoutingMetadata(r, accessResult.warning), accessResult.warning)
1546
- : r);
1876
+ // Genuine error reaching the backend — default unknown difficulty to premium/Opus,
1877
+ // stamping the access diagnostic onto every eligible row.
1878
+ return applyPremiumFallbackToEligibleRows(deps, rows, isEligible, agent, null, accessResult.warning, accessResult.diagnostic);
1547
1879
  }
1548
1880
  const access = accessResult.access;
1549
1881
  const configResult = await fetchDifficultyModelRoutingConfig(access);
1550
1882
  if (!configResult.ok) {
1551
- return rows.map((r) => isEligible(r)
1552
- ? appendSummaryRowWarning(applyDefaultModelRoutingMetadata(r, configResult.warning), configResult.warning)
1553
- : r);
1883
+ // Can't read routing config — treat as error and default to premium/Opus.
1884
+ return applyPremiumFallbackToEligibleRows(deps, rows, isEligible, agent, null, configResult.warning, configResult.diagnostic);
1554
1885
  }
1555
1886
  const { enabled, overrides } = configResult.config;
1556
1887
  if (!enabled) {
@@ -1558,6 +1889,14 @@ async function resolveModelRoutingForEligible(deps, rows, options, agent, isElig
1558
1889
  return rows.map((r) => (isEligible(r) ? applyDefaultModelRoutingMetadata(r, reason) : r));
1559
1890
  }
1560
1891
  const tierMap = await fetchTicketModelTiersForRows(access, eligible, options.maxParallel, isEligible);
1892
+ // Lazily resolve+validate the premium fallback alias at most once for this run
1893
+ // (the alias is identical across rows; cursor-agent validation spawns a probe).
1894
+ let premiumApplier = null;
1895
+ const applyPremiumFallback = async (row, warning, diagnostic) => {
1896
+ if (!premiumApplier)
1897
+ premiumApplier = await makePremiumFallbackApplier(deps, agent, overrides);
1898
+ return premiumApplier(row, warning, diagnostic);
1899
+ };
1561
1900
  const out = [];
1562
1901
  for (const row of rows) {
1563
1902
  if (!isEligible(row)) {
@@ -1566,10 +1905,21 @@ async function resolveModelRoutingForEligible(deps, rows, options, agent, isElig
1566
1905
  }
1567
1906
  const tierResult = tierMap.get(row.key);
1568
1907
  if (!tierResult || !tierResult.ok) {
1908
+ // Error reaching the per-ticket tier endpoint — default to premium/Opus,
1909
+ // stamping the per-ticket tier-fetch diagnostic.
1569
1910
  const warning = tierResult && !tierResult.ok
1570
1911
  ? tierResult.warning
1571
- : `model routing: no tier resolved for ${row.key}; using agent default`;
1572
- out.push(appendSummaryRowWarning(applyDefaultModelRoutingMetadata(row, warning), warning));
1912
+ : `model routing: no tier resolved for ${row.key}; assuming hard ticket`;
1913
+ const diagnostic = tierResult && !tierResult.ok
1914
+ ? tierResult.diagnostic
1915
+ : makeModelRoutingDiagnostic({
1916
+ kind: "tier-fetch-unavailable",
1917
+ repoName: access.repoName,
1918
+ storeTarget: `bapi:${access.repoName}`,
1919
+ ticketKey: row.key,
1920
+ message: `no tier resolved for ${row.key}`,
1921
+ });
1922
+ out.push(await applyPremiumFallback(row, warning, diagnostic));
1573
1923
  continue;
1574
1924
  }
1575
1925
  const { difficulty, tier, source } = tierResult.value;
@@ -1580,8 +1930,17 @@ async function resolveModelRoutingForEligible(deps, rows, options, agent, isElig
1580
1930
  modelRoutingSource: source,
1581
1931
  };
1582
1932
  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));
1933
+ // Backend reported no usable difficulty default to premium/Opus. (The
1934
+ // backend now returns a premium fallback itself, so this is defensive.)
1935
+ const warning = `model routing: ${row.key} resolved no tier (source=${source}); assuming hard ticket`;
1936
+ const diagnostic = makeModelRoutingDiagnostic({
1937
+ kind: "no-tier",
1938
+ repoName: access.repoName,
1939
+ storeTarget: `bapi:${access.repoName}`,
1940
+ ticketKey: row.key,
1941
+ message: `${row.key} resolved no tier (source=${source})`,
1942
+ });
1943
+ out.push(await applyPremiumFallback(baseRow, warning, diagnostic));
1585
1944
  continue;
1586
1945
  }
1587
1946
  const alias = resolveModelAlias(agent, tier, overrides);
@@ -1632,7 +1991,175 @@ export function formatModelRoutingLine(row, agent) {
1632
1991
  const model = row.modelAlias ?? "default";
1633
1992
  return `${row.key} difficulty=${difficulty} tier=${tier} agent=${agent.name} model=${model}`;
1634
1993
  }
1994
+ /** Stable de-dup key for an invocation-level routing diagnostic. */
1995
+ function modelRoutingDiagnosticKey(d) {
1996
+ return [
1997
+ d.kind,
1998
+ d.repoName ?? "",
1999
+ d.storeTarget ?? "",
2000
+ d.storePath ?? "",
2001
+ d.ticketKey ?? "",
2002
+ typeof d.status === "number" ? String(d.status) : "",
2003
+ ].join("|");
2004
+ }
2005
+ /**
2006
+ * Scan routed rows, extract `modelRoutingDiagnostic`, and de-duplicate by stable
2007
+ * key. An identical credential problem shared by many rows collapses to one
2008
+ * diagnostic (its `ticketKey` is undefined), while distinct per-ticket failures
2009
+ * (which carry a `ticketKey`) are preserved.
2010
+ */
2011
+ export function collectInvocationModelRoutingDiagnostics(rows) {
2012
+ const byKey = new Map();
2013
+ for (const row of rows) {
2014
+ const d = row.modelRoutingDiagnostic;
2015
+ if (!d)
2016
+ continue;
2017
+ const key = modelRoutingDiagnosticKey(d);
2018
+ if (!byKey.has(key))
2019
+ byKey.set(key, d);
2020
+ }
2021
+ return [...byKey.values()];
2022
+ }
2023
+ /** Priority rank (lower = more important) for choosing the primary diagnostic. */
2024
+ function modelRoutingDiagnosticPriority(kind) {
2025
+ switch (kind) {
2026
+ case "repo-missing":
2027
+ case "credential-not-found":
2028
+ case "credential-missing-key":
2029
+ case "credential-read-error":
2030
+ case "credential-parse-error":
2031
+ return 0;
2032
+ case "unauthorized":
2033
+ return 1;
2034
+ case "server":
2035
+ case "network":
2036
+ case "http":
2037
+ case "config-unavailable":
2038
+ case "tier-fetch-unavailable":
2039
+ return 2;
2040
+ case "no-tier":
2041
+ return 3;
2042
+ default:
2043
+ return 4;
2044
+ }
2045
+ }
2046
+ /**
2047
+ * Select the highest-priority diagnostic when several degraded states coexist:
2048
+ * credential setup issues first, then unauthorized, then server/network/config,
2049
+ * then no-tier. Returns null when there are none.
2050
+ */
2051
+ export function selectPrimaryInvocationModelRoutingDiagnostic(diagnostics) {
2052
+ let best = null;
2053
+ let bestRank = Number.POSITIVE_INFINITY;
2054
+ for (const d of diagnostics) {
2055
+ const rank = modelRoutingDiagnosticPriority(d.kind);
2056
+ if (rank < bestRank) {
2057
+ best = d;
2058
+ bestRank = rank;
2059
+ }
2060
+ }
2061
+ return best;
2062
+ }
2063
+ /**
2064
+ * Emit at most ONE actionable routing diagnostic line to stderr for this
2065
+ * invocation. The human-facing stderr line is mandatory and independent of any
2066
+ * telemetry hook; `reportingPolicy` is carried for a future Sentry path, but the
2067
+ * CLI never emits Sentry events for expected setup gaps (missing/unauthorized
2068
+ * credentials). No-op when there is no diagnostic.
2069
+ */
2070
+ export function emitInvocationModelRoutingDiagnostic(diagnostic, errorLog) {
2071
+ if (!diagnostic)
2072
+ return;
2073
+ errorLog(formatModelRoutingDiagnosticLine(diagnostic));
2074
+ }
2075
+ /** Convenience: collect → select → emit the single invocation diagnostic. */
2076
+ export function emitInvocationModelRoutingDiagnosticForRows(rows, errorLog) {
2077
+ emitInvocationModelRoutingDiagnostic(selectPrimaryInvocationModelRoutingDiagnostic(collectInvocationModelRoutingDiagnostics(rows)), errorLog);
2078
+ }
2079
+ /**
2080
+ * BAPI-394 conductor provisioning step (run id, per-worker id/env, Claude hook
2081
+ * injection, run-level `run.started`). Fully fail-open: any unexpected failure
2082
+ * returns the input rows unchanged so a conductor problem never aborts a real
2083
+ * start-tickets run. Per-worktree hook failures are handled inside
2084
+ * {@link provisionConductorHooksForRows} (they mark only the affected row).
2085
+ */
2086
+ async function provisionConductorForRows(rows, deps, options, agent, overrides) {
2087
+ const createConductorContextFn = overrides.createConductorContext ?? createStartTicketsConductorContext;
2088
+ const provisionConductorFn = overrides.provisionConductorHooksForRows ?? provisionConductorHooksForRows;
2089
+ const emitRunStartedFn = overrides.emitStartTicketsRunStarted ?? emitStartTicketsRunStarted;
2090
+ try {
2091
+ const context = await createConductorContextFn({ keys: options.keys, autoApprove: options.autoApprove, epic: options.epic }, agent, {
2092
+ env: deps.env,
2093
+ cwd: deps.cwd,
2094
+ readFile: (filePath) => readFile(filePath, "utf-8"),
2095
+ });
2096
+ const provisioned = await provisionConductorFn(rows, context, {
2097
+ readFile: (filePath) => readFile(filePath, "utf-8"),
2098
+ writeFile: (filePath, data) => writeFile(filePath, data, "utf-8"),
2099
+ mkdir: (dirPath, opts) => mkdir(dirPath, opts),
2100
+ env: deps.env,
2101
+ });
2102
+ const withRunStarted = await emitRunStartedFn(context, provisioned, {
2103
+ keys: options.keys,
2104
+ dryRun: options.dryRun,
2105
+ });
2106
+ return { rows: withRunStarted, context };
2107
+ }
2108
+ catch {
2109
+ // Conductor observability is best-effort; never abort the run.
2110
+ return { rows, context: null };
2111
+ }
2112
+ }
2113
+ /**
2114
+ * BAPI-396: launch the conductor supervisor as a visible peer terminal tab for
2115
+ * the run. Best-effort and run-AFTER `run.started`: a spawn failure records a
2116
+ * `failed` status (workers still spawn) and never aborts the run. Disabled
2117
+ * (`supervisorMode=off`) or dry-run launches are reported as `skipped` without
2118
+ * spawning. Returns the resolved {@link SupervisorLaunchStatus}.
2119
+ */
2120
+ async function launchSupervisorTab(context, deps, options, terminal) {
2121
+ if (options.dryRun || !isSupervisorLaunchEnabled(context)) {
2122
+ return "skipped";
2123
+ }
2124
+ try {
2125
+ const shellCommand = buildSupervisorTabCommand(context, deps.platform);
2126
+ const result = await deps.spawnTerminalTab(deps, terminal, shellCommand, {
2127
+ key: supervisorSpawnKey(options.keys),
2128
+ worktreePath: deps.cwd,
2129
+ });
2130
+ return result.ok ? "spawned" : "failed";
2131
+ }
2132
+ catch {
2133
+ return "failed";
2134
+ }
2135
+ }
2136
+ /** Stamp the supervisor launch status onto every row (run-level field). */
2137
+ function stampSupervisorStatus(rows, status) {
2138
+ return rows.map((row) => ({ ...row, supervisorStatus: status }));
2139
+ }
2140
+ /**
2141
+ * Default epic dispatch correlation implementation (BAPI-409 idempotency guard).
2142
+ * Resolves Bridge API credentials from the environment and POSTs a run_spawned
2143
+ * transition for the given dispatch key before tabs are spawned. Throws on any
2144
+ * failure so the caller can abort the spawn (preventing duplicate dispatches).
2145
+ */
2146
+ async function defaultClaimEpicDispatch(dispatchKey, runId, deps) {
2147
+ const accessResult = await resolveConductorBridgeApiAccess({ env: deps.env, cwd: deps.cwd });
2148
+ if (!accessResult.ok) {
2149
+ throw new Error(`Epic dispatch correlation failed: ${accessResult.error}`);
2150
+ }
2151
+ await transitionEpicDispatch(accessResult.access, {
2152
+ dispatchKey,
2153
+ nextStatus: "run_spawned",
2154
+ runId,
2155
+ });
2156
+ }
1635
2157
  export async function orchestrateStartTickets(deps, options, overrides = {}) {
2158
+ // Thread dependency-derived base branch from epic identity through the
2159
+ // existing --base-branch path (no new branch-cutting or refresh logic).
2160
+ if (options.epic?.base_branch) {
2161
+ options = { ...options, baseBranch: options.epic.base_branch };
2162
+ }
1636
2163
  if (options.dryRun) {
1637
2164
  return { ok: true, rows: buildDryRunResults(options.keys, options.branchOverrides) };
1638
2165
  }
@@ -1673,20 +2200,85 @@ export async function orchestrateStartTickets(deps, options, overrides = {}) {
1673
2200
  // and BEFORE tab spawning (currently a pass-through seam; see
1674
2201
  // materializeFileCredentialsForCreatedWorktrees).
1675
2202
  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.
2203
+ // BAPI-394: provision conductor identity (run id, per-worker id + env, Claude
2204
+ // hook injection) and emit the run-level `run.started` event AFTER
2205
+ // materialization and BEFORE model routing. The conductor stage is opted into
2206
+ // by the caller supplying the `createConductorContext` seam (the packaged CLI
2207
+ // does this); direct callers/tests that omit it get the pre-conductor pipeline
2208
+ // unchanged. Fully fail-open: any unexpected failure here leaves the run
2209
+ // untouched (no conductor identity) rather than aborting the spawn.
2210
+ // BAPI-396: launch the supervisor peer tab AFTER `run.started` is emitted
2211
+ // (inside provisionConductorForRows) and BEFORE worker tabs spawn. Fully
2212
+ // best-effort: a failure marks the run-level status `failed` but workers still
2213
+ // spawn. `terminal` is resolved up-front so the supervisor tab and worker tabs
2214
+ // share the same terminal choice.
2215
+ // BAPI-409: defensive check — epic dispatch requires conductor context to mint
2216
+ // a run_id for correlation. Fail fast rather than silently bypassing the claim.
2217
+ if (options.epic?.dispatch_key && !overrides.createConductorContext) {
2218
+ return {
2219
+ ok: false,
2220
+ error: "Epic dispatch requires conductor context (createConductorContext seam must be injected when dispatch_key is set)",
2221
+ };
2222
+ }
2223
+ const terminal = detectTerminalFn(options.terminal, deps.env);
2224
+ let conductorReady;
2225
+ if (overrides.createConductorContext) {
2226
+ const provisionResult = await provisionConductorForRows(materialized, deps, options, agent, overrides);
2227
+ // BAPI-409: if epic dispatch requires a claim but conductor provisioning
2228
+ // failed (context is null from its own fail-open error handler), treat it
2229
+ // as a HARD error — we cannot mint or correlate a run_id without context,
2230
+ // and spawning without claiming would allow ghost re-dispatches on the next
2231
+ // epic-tick wakeup.
2232
+ if (options.epic?.dispatch_key && !provisionResult.context) {
2233
+ return {
2234
+ ok: false,
2235
+ error: "Epic dispatch correlation unavailable: conductor provisioning failed (cannot claim dispatch without run_id)",
2236
+ };
2237
+ }
2238
+ if (provisionResult.context) {
2239
+ // BAPI-409: claim the dispatch_key BEFORE spawning tabs. The idempotency
2240
+ // guard ensures that if this invocation crashes after spawn the next
2241
+ // epic-tick wakeup cannot re-dispatch the same run. Failures are HARD
2242
+ // errors (not fail-open) to prevent ghost spawns.
2243
+ if (options.epic?.dispatch_key) {
2244
+ const claimFn = overrides.claimEpicDispatch ?? defaultClaimEpicDispatch;
2245
+ try {
2246
+ await claimFn(options.epic.dispatch_key, provisionResult.context.runId, deps);
2247
+ }
2248
+ catch (e) {
2249
+ const msg = e instanceof Error ? e.message : "epic dispatch correlation failed";
2250
+ return { ok: false, error: `Epic dispatch correlation failed (idempotency guard): ${msg}` };
2251
+ }
2252
+ }
2253
+ const supervisorStatus = await launchSupervisorTab(provisionResult.context, deps, options, terminal);
2254
+ conductorReady = stampSupervisorStatus(provisionResult.rows, supervisorStatus);
2255
+ }
2256
+ else {
2257
+ conductorReady = provisionResult.rows;
2258
+ }
2259
+ }
2260
+ else {
2261
+ conductorReady = materialized;
2262
+ }
2263
+ // BAPI-365: resolve per-ticket model routing AFTER conductor provisioning and
2264
+ // BEFORE building the spawn command. Always fail-open: routing never aborts a
2265
+ // spawn — at worst a ticket runs on the agent's default model.
1679
2266
  const resolveRoutingFn = overrides.resolveModelRoutingForRows ?? resolveModelRoutingForRows;
1680
- const routed = await resolveRoutingFn(deps, materialized, options, agent);
2267
+ const routed = await resolveRoutingFn(deps, conductorReady, options, agent);
2268
+ // Per-ticket routing DECISION lines stay (one per spawnable ticket). The
2269
+ // noisy per-row routing WARNING emission is intentionally gone (BAPI-377): the
2270
+ // CLI now emits a single de-duplicated, actionable routing diagnostic per
2271
+ // invocation from the structured `modelRoutingDiagnostic` stamped on rows.
1681
2272
  for (const row of routed) {
1682
2273
  if (row.status !== "created" || !row.path)
1683
2274
  continue;
1684
2275
  overrides.modelRoutingLog?.(formatModelRoutingLine(row, agent));
1685
- if (row.modelAlias == null && row.modelRoutingReason) {
1686
- overrides.modelRoutingWarningLog?.(`${row.key}: ${row.modelRoutingReason}`);
1687
- }
1688
2276
  }
1689
- const terminal = detectTerminalFn(options.terminal, deps.env);
2277
+ // Single invocation-level routing diagnostic to stderr (de-duplicated across
2278
+ // rows). Fail-open: emission is best-effort and never blocks spawning.
2279
+ if (overrides.modelRoutingWarningLog) {
2280
+ emitInvocationModelRoutingDiagnosticForRows(routed, overrides.modelRoutingWarningLog);
2281
+ }
1690
2282
  const rows = await spawnTabsFn(deps, routed, terminal, platformConfig.config.buildAgentShellCommand);
1691
2283
  return { ok: true, rows };
1692
2284
  }
@@ -1759,6 +2351,9 @@ export async function runStartTicketsCli(argv, overrides = {}) {
1759
2351
  }
1760
2352
  }
1761
2353
  log("");
2354
+ // Preview lines and summaries stay on stdout; the single actionable routing
2355
+ // diagnostic (de-duplicated across tickets) goes to stderr, matching a real run.
2356
+ emitInvocationModelRoutingDiagnosticForRows(routedDryRunRows, errorLog);
1762
2357
  }
1763
2358
  // Thread the routing decision/warning sinks through to the orchestrator. The
1764
2359
  // dry-run path short-circuits inside orchestrate before any routing (the
@@ -1766,6 +2361,12 @@ export async function runStartTicketsCli(argv, overrides = {}) {
1766
2361
  const result = await orchestrate(deps, options, {
1767
2362
  modelRoutingLog: log,
1768
2363
  modelRoutingWarningLog: errorLog,
2364
+ // BAPI-394: opt the packaged CLI into conductor observability. Supplying the
2365
+ // context seam activates the conductor stage inside orchestrate; the other
2366
+ // two seams fall back to their real module implementations.
2367
+ createConductorContext: createStartTicketsConductorContext,
2368
+ provisionConductorHooksForRows,
2369
+ emitStartTicketsRunStarted,
1769
2370
  });
1770
2371
  if (!result.ok) {
1771
2372
  errorLog(`Error: ${result.error}`);