@bridge_gpt/mcp-server 0.2.58 → 0.2.59

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 (86) hide show
  1. package/README.md +105 -109
  2. package/build/agent-registry.js +15 -0
  3. package/build/agent-utils.js +16 -8
  4. package/build/claude-user-config-doctor.js +1 -35
  5. package/build/codex-bridge-profile.js +1 -1
  6. package/build/commands.generated.js +5 -6
  7. package/build/conductor/bridge-api-client.js +149 -0
  8. package/build/conductor/bring-up-facts.js +1 -1
  9. package/build/conductor/claude-hook.js +16 -16
  10. package/build/conductor/doctor.js +24 -19
  11. package/build/conductor/epic-implementer-cli.js +38 -5
  12. package/build/conductor/epic-reconcile.js +1 -1
  13. package/build/conductor/epic-runtime.js +10 -10
  14. package/build/conductor/errors.js +3 -3
  15. package/build/conductor/file-scope-guard.js +4 -4
  16. package/build/conductor/git-ci-types.js +1 -1
  17. package/build/conductor/git-inspection.js +2 -2
  18. package/build/conductor/install-doctor.js +6 -6
  19. package/build/conductor/merge-ledger.js +2 -2
  20. package/build/conductor/pr-ci-producer.js +6 -6
  21. package/build/conductor/pr-review-producer.js +2 -2
  22. package/build/conductor/producer-ledger.js +2 -0
  23. package/build/conductor/readiness-sections.js +7 -7
  24. package/build/conductor/readiness.js +2 -2
  25. package/build/conductor/recovery-cli.js +243 -12
  26. package/build/conductor/recovery-operations.js +56 -4
  27. package/build/conductor/repair-contract.js +38 -4
  28. package/build/conductor/spec-review-producer.js +6 -6
  29. package/build/conductor/store.js +10 -10
  30. package/build/conductor/supervisor-config.js +14 -14
  31. package/build/conductor/supervisor-ledger.js +2 -2
  32. package/build/conductor/supervisor-message-relay.js +2 -2
  33. package/build/conductor/worker-ledger-cli.js +19 -19
  34. package/build/conductor-bundle-artifacts.js +2 -2
  35. package/build/doctor.js +16 -294
  36. package/build/drive-epic.js +65 -71
  37. package/build/epic-implementer-bin.js +22 -17
  38. package/build/epic-implementer-claude-hook-bin.js +4 -2
  39. package/build/epic-implementer-env.js +155 -0
  40. package/build/epic-integration-pr.js +2 -2
  41. package/build/epic-shared/bridge-client.js +773 -0
  42. package/build/epic-shared/cut-protocol.js +392 -0
  43. package/build/executor/env.js +19 -8
  44. package/build/executor/heartbeat.js +1 -1
  45. package/build/executor/job-errors.js +3 -3
  46. package/build/executor/job-runner.js +1 -1
  47. package/build/executor/merge-job.js +35 -3
  48. package/build/executor/observation.js +1 -1
  49. package/build/executor/preflight.js +1 -1
  50. package/build/executor/prompt-spec.js +1 -1
  51. package/build/executor/reconcile-overlap-job.js +10 -130
  52. package/build/executor/reconcile-overlap-types.js +1 -1
  53. package/build/executor/runner.js +1 -1
  54. package/build/implement-epic/cli.js +1 -1
  55. package/build/index.js +181 -218
  56. package/build/init.js +20 -28
  57. package/build/install-bridge-conductor.js +7 -7
  58. package/build/install-bridge.js +199 -7
  59. package/build/mcp-host-entry-adapter.js +6 -0
  60. package/build/mcp-host-targets.js +2 -0
  61. package/build/mcp-invoke.js +5 -5
  62. package/build/mcp-profile.js +2 -2
  63. package/build/mcp-provisioning.js +2 -2
  64. package/build/mcp-server-invocation.js +3 -3
  65. package/build/merge-pull-request.js +15 -0
  66. package/build/model-routing-host.js +25 -0
  67. package/build/model-routing-preferences.js +99 -0
  68. package/build/pipelines.generated.js +9 -9
  69. package/build/plan-epic-implementer-eligibility.js +215 -0
  70. package/build/plan-execution-ledger.js +8 -0
  71. package/build/plane/member-roster.js +5 -5
  72. package/build/plane/supervisor.js +1 -1
  73. package/build/plane/types.js +5 -5
  74. package/build/readiness-check.js +1 -1
  75. package/build/readme.generated.js +1 -1
  76. package/build/review-tickets.js +4 -4
  77. package/build/setup-epic.js +8 -8
  78. package/build/start-tickets-conductor.js +32 -32
  79. package/build/start-tickets-prereqs.js +4 -9
  80. package/build/start-tickets.js +475 -91
  81. package/build/version.generated.js +3 -3
  82. package/docs/CONDUCTOR.md +69 -18
  83. package/docs/install/mcp-tool-integrations.md +2 -1
  84. package/package.json +2 -2
  85. package/pipelines/implement-ticket.json +1 -1
  86. package/pipelines/plan-epic.json +2 -2
@@ -77,7 +77,7 @@ import { TICKET_KEY_PATTERN as SHARED_TICKET_KEY_PATTERN, TICKET_KEY_USAGE_SUMMA
77
77
  // can never drift. `start-tickets.ts` imports VALUES from there; the prereqs
78
78
  // module imports only TYPES back, so the runtime graph stays acyclic.
79
79
  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";
80
- import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, resolveModelAlias, isValidModelAlias, isModelTier, } from "./agent-registry.js";
80
+ import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, resolveModelAlias, isValidModelAlias, isModelTier, downshiftModelTier, } from "./agent-registry.js";
81
81
  import { agentForPlatform, getHostTarget, getWorktreeHostTargets, isRunnableHostTarget, isHostPlatformId } from "./mcp-host-targets.js";
82
82
  import { resolveCodexLaunchScope } from "./codex-launch-scope.js";
83
83
  import { tmpdir } from "node:os";
@@ -135,7 +135,8 @@ export function getStartTicketsUsage() {
135
135
  " --host claude-code|cursor|codex|copilot-vscode|windsurf Current host when --agent is omitted; Claude Code, Cursor, and Codex can launch sessions",
136
136
  " Cursor with --auto launches headlessly after a bounded readiness check; without --auto it stays interactive for the review gate.",
137
137
  " --workflow implement|review-and-implement Command each spawned worktree runs (default: implement). review-and-implement runs review-ticket then, after a per-ticket halt gate, hands off to a fresh implement-ticket session; --auto applies to both phases.",
138
- " --tier cheap|basic|premium Coarse model-routing override: bypasses the per-ticket difficulty/tier lookup and applies this tier to every ticket. Claude/Cursor use registry aliases and repository overrides; Codex uses the authenticated Bridge catalog. It is NOT a raw --model alias and never carries an API key or credential. A malformed value requests premium routing when available.",
138
+ " --tier cheap|basic|premium Strict manual model-routing override: bypasses the per-ticket difficulty/tier lookup and applies this tier to every ticket exactly as supplied, with no per-session posture adjustment. Claude/Cursor use registry aliases and repository overrides; Codex uses the authenticated Bridge catalog. It is NOT a raw --model alias and never carries an API key or credential. A malformed value requests premium routing when available. Mutually exclusive with --auto-tier.",
139
+ " --auto-tier cheap|basic|premium Internal/advanced machine-carried raw backend tier for chained automation handoffs (e.g. /review-and-implement): bypasses the per-ticket lookup like --tier, but remains subject to per-session optimization-posture adjustment (may be downshifted under maximize_subscription). Not the strict-override mechanism — use --tier for a human override. Mutually exclusive with --tier.",
139
140
  " --rounds 1|2 Review round count forwarded to the review phase; review-only, valid only with --workflow review-and-implement",
140
141
  " --terminal terminal|iterm Override the macOS terminal app (default: auto-detect via $TERM_PROGRAM); honored on macOS only",
141
142
  " --dry-run Print intended actions; creates no worktrees and opens no tabs, but DOES resolve model routing read-only (may compute+cache a ticket's difficulty) to preview the --model each tab would use",
@@ -143,16 +144,16 @@ export function getStartTicketsUsage() {
143
144
  " --base-branch BRANCH Cut new worktrees from BRANCH and refresh origin/BRANCH (default: main)",
144
145
  " --no-refresh-main Skip refresh of the configured base branch (default main); historical name retained for backward compatibility",
145
146
  " --max-parallel N Max worktrees to create concurrently (default: 3)",
146
- " --conductor Enable the Conductor system: per-worker hook injection + BAPI_CONDUCTOR_* env, lifecycle ledger events, and a supervisor peer tab (default: off — a plain run just spawns cd <worktree> && <agent> '/implement-ticket <KEY>'). Implies --guard-stale-branch: a pre-existing ticket branch carrying commits beyond the resolved base is refused, not silently reused.",
147
+ " --conductor Enable the Conductor system: per-worker hook injection + BAPI_EPIC_IMPLEMENTER_* env, lifecycle ledger events, and a supervisor peer tab (default: off — a plain run just spawns cd <worktree> && <agent> '/implement-ticket <KEY>'). Implies --guard-stale-branch: a pre-existing ticket branch carrying commits beyond the resolved base is refused, not silently reused.",
147
148
  " --guard-stale-branch Refuse to reuse a pre-existing feature/KEY branch whose tip is NOT an ancestor of the resolved base (it carries commits the base does not). The ticket is reported create-failed with the stale-worktree remedy instead of spawning a worker on leftover code. Implied by --conductor; pass it explicitly on the LLM-conductor pilot cut path. Off by default, so a plain interactive run keeps reusing a same-named branch.",
148
149
  " -h, --help Show this help",
149
150
  "",
150
151
  "Environment:",
151
152
  ` ${WORKTRUNK_BINARY_OVERRIDE_ENV} Override the Worktrunk executable name/path for nonstandard installs`,
152
153
  ` ${TMUX_SESSION_OVERRIDE_ENV} Override the tmux session-name prefix on Linux (default: ${DEFAULT_TMUX_SESSION_PREFIX})`,
153
- " BAPI_CONDUCTOR_GATE_NAME Conductor gate name for this run (default: implement-ticket)",
154
- " BAPI_CONDUCTOR_SUPERVISOR_MODE Conductor supervisor mode (default: auto when --auto, else interactive)",
155
- " BAPI_CONDUCTOR_ENABLE_PRE_TOOL_USE Set 1/true to also register a PreToolUse conductor hook",
154
+ " BAPI_EPIC_IMPLEMENTER_GATE_NAME Conductor gate name for this run (default: implement-ticket)",
155
+ " BAPI_EPIC_IMPLEMENTER_SUPERVISOR_MODE Conductor supervisor mode (default: auto when --auto, else interactive)",
156
+ " BAPI_EPIC_IMPLEMENTER_ENABLE_PRE_TOOL_USE Set 1/true to also register a PreToolUse conductor hook",
156
157
  "",
157
158
  "Conductor observability (opt-in via --conductor):",
158
159
  " With --conductor, real Claude Code workers launched by start-tickets receive",
@@ -160,7 +161,7 @@ export function getStartTicketsUsage() {
160
161
  " local lifecycle events into the conductor ledger. Each such run mints one run_id",
161
162
  " and attributes worker events by worker_id, ticket key, and worktree path, and a",
162
163
  " supervisor peer tab is opened. Without --conductor none of this happens. Inspect",
163
- " the ledger with the `conductor` CLI. The BAPI_CONDUCTOR_* env vars above apply",
164
+ " the ledger with the `conductor` CLI. The BAPI_EPIC_IMPLEMENTER_* env vars above apply",
164
165
  " only when --conductor is set.",
165
166
  "",
166
167
  "Prerequisites:",
@@ -207,6 +208,13 @@ export function parseStartTicketsArgs(argv) {
207
208
  // the parsed options); a `ModelTier` = usable override; INJECTED_TIER_UNRESOLVED
208
209
  // = supplied-but-malformed (fail open to premium). NEVER an error/throw.
209
210
  let injectedTier;
211
+ // BAPI-1236 `--auto-tier` machine-carried raw tier. `undefined` = flag absent.
212
+ // Unlike `--tier`, an invalid/repeated occurrence is a hard parser error —
213
+ // there is no fail-open sentinel — because this flag is meant for chained
214
+ // automation, not interactive typing, so a malformed value should surface
215
+ // immediately rather than silently degrade routing.
216
+ let autoTier;
217
+ let autoTierSeen = false;
210
218
  const branchEntries = [];
211
219
  const keys = [];
212
220
  for (let i = 0; i < argv.length; i++) {
@@ -310,6 +318,39 @@ export function parseStartTicketsArgs(argv) {
310
318
  injectedTier = isModelTier(value) ? value : INJECTED_TIER_UNRESOLVED;
311
319
  continue;
312
320
  }
321
+ if (arg === "--auto-tier" || arg.startsWith("--auto-tier=")) {
322
+ // BAPI-1236: strict machine-carried tier boundary. Unlike `--tier`, a
323
+ // malformed/empty/missing/repeated value is a hard parser error — there
324
+ // is no INJECTED_TIER_UNRESOLVED-style fail-open sentinel for this flag.
325
+ let value;
326
+ if (arg.startsWith("--auto-tier=")) {
327
+ value = arg.slice("--auto-tier=".length);
328
+ }
329
+ else {
330
+ // Same lookahead rule as `--tier`: never swallow a following option or
331
+ // ticket-key token as the value.
332
+ const next = i + 1 < argv.length ? argv[i + 1] : undefined;
333
+ if (next !== undefined && !next.startsWith("-") && !TICKET_KEY_PATTERN.test(next)) {
334
+ value = takeValue();
335
+ }
336
+ }
337
+ // Validate the value directly with `isModelTier()` — no trimming, case
338
+ // normalization, or reuse of `INJECTED_TIER_UNRESOLVED` — before ever
339
+ // checking repetition, so a malformed occurrence always fails as an
340
+ // invalid-value error rather than a repeated-flag or mutual-exclusion one.
341
+ if (!isModelTier(value)) {
342
+ return {
343
+ status: "error",
344
+ message: `Invalid --auto-tier value: '${value ?? ""}' (accepted values: cheap, basic, premium).`,
345
+ };
346
+ }
347
+ if (autoTierSeen) {
348
+ return { status: "error", message: "--auto-tier may only be supplied once." };
349
+ }
350
+ autoTierSeen = true;
351
+ autoTier = value;
352
+ continue;
353
+ }
313
354
  if (arg === "--terminal" || arg.startsWith("--terminal=")) {
314
355
  let value;
315
356
  if (arg.startsWith("--terminal=")) {
@@ -404,6 +445,18 @@ export function parseStartTicketsArgs(argv) {
404
445
  }
405
446
  keys.push(arg);
406
447
  }
448
+ // --- tier-flag mutual exclusion (BAPI-1236) ---
449
+ // Checked immediately after the full argument scan, before any ticket/option
450
+ // semantic validation, so the conflict exits before orchestration or any
451
+ // network-capable dependency can run. Fires whenever BOTH flag names were
452
+ // supplied at all — including a malformed `--tier` (INJECTED_TIER_UNRESOLVED),
453
+ // since the flag was still supplied — regardless of argument order.
454
+ if (injectedTier !== undefined && autoTier !== undefined) {
455
+ return {
456
+ status: "error",
457
+ message: "--tier and --auto-tier cannot be used together.",
458
+ };
459
+ }
407
460
  // --- key validation ---
408
461
  if (keys.length === 0) {
409
462
  return {
@@ -508,6 +561,9 @@ export function parseStartTicketsArgs(argv) {
508
561
  // supplied, so an omitted flag leaves the legacy option shape untouched
509
562
  // (no own `injectedTier` key, not even `undefined`).
510
563
  ...(injectedTier !== undefined ? { injectedTier } : {}),
564
+ // BAPI-1236: include the auto-tier property ONLY when `--auto-tier` was
565
+ // supplied, mirroring `injectedTier`'s omitted-flag contract.
566
+ ...(autoTier !== undefined ? { autoTier } : {}),
511
567
  },
512
568
  };
513
569
  }
@@ -2203,7 +2259,7 @@ export function buildMcpProvisioningDeps(deps, bridgeApiBaseUrlOverride) {
2203
2259
  // conductor's own on-disk build (absolute-build-path primary, npm-channel
2204
2260
  // fallback) — never from process.cwd(). `nodeExecutable` is kept as "node"
2205
2261
  // here: Ticket 3 (P2) can later replace this single field with
2206
- // CONDUCTOR_NODE_PATH without re-plumbing argv construction.
2262
+ // EPIC_IMPLEMENTER_NODE_PATH without re-plumbing argv construction.
2207
2263
  mcpServerInvocation: resolveMcpShimInvocationForRuntime({
2208
2264
  moduleUrl: import.meta.url,
2209
2265
  nodeExecutable: "node",
@@ -2323,6 +2379,9 @@ export function formatModelRoutingDiagnosticLine(d) {
2323
2379
  return `model routing degraded: Codex model catalog failed validation. ${failOpen}`;
2324
2380
  case "codex-entitlement-unverified":
2325
2381
  return "model routing: Bridge permits the selected model; local Codex account entitlement cannot be verified non-interactively.";
2382
+ case "posture-unavailable":
2383
+ return ("model routing: optimization posture was unavailable; Quality routing was retained " +
2384
+ "(reason=optimization-posture-unavailable).");
2326
2385
  default:
2327
2386
  return `model routing degraded: ${d.message} ${failOpen}`;
2328
2387
  }
@@ -2568,6 +2627,77 @@ export async function fetchDifficultyModelRoutingConfig(access, includeOverrides
2568
2627
  };
2569
2628
  }
2570
2629
  }
2630
+ /** Type guard: true only for a recognized posture value. */
2631
+ export function isOptimizationPosture(value) {
2632
+ return value === "quality" || value === "speed" || value === "maximize_subscription";
2633
+ }
2634
+ const POSTURE_UNAVAILABLE_WARNING = "model routing: optimization posture was unavailable; Quality routing was retained " +
2635
+ "(reason=optimization-posture-unavailable)";
2636
+ /**
2637
+ * BAPI-1225: the single stable message every posture-resolution emitter uses,
2638
+ * verbatim. Textually identical to (but independently defined from) the Python
2639
+ * contract in `src/python/library/optimization_posture_observability.py` — the
2640
+ * two runtimes share no import, only the agreed-on literal string.
2641
+ */
2642
+ const OPTIMIZATION_POSTURE_RESOLVED_MESSAGE = "optimization posture resolved";
2643
+ /** BAPI-1225: this harness diagnostic's fixed resolution-path value. */
2644
+ const RESOLUTION_PATH_HARNESS_ROUTING = "harness_routing";
2645
+ /**
2646
+ * Format the BAPI-1225 repository-level harness routing-diagnostic line.
2647
+ * Deterministic and secret-free: only the normalized closed posture value, the
2648
+ * canonical repo name already supplied to routing-config resolution, and the
2649
+ * constant resolution path — no raw config, resolved alias/model/tier, file
2650
+ * path, ticket content, error, credential, or fallback provenance.
2651
+ */
2652
+ function formatOptimizationPostureResolvedLine(posture, repoName) {
2653
+ return (`${OPTIMIZATION_POSTURE_RESOLVED_MESSAGE} optimization_posture=${posture} ` +
2654
+ `repo_name=${repoName} resolution_path=${RESOLUTION_PATH_HARNESS_ROUTING}`);
2655
+ }
2656
+ /** Build the shared, secret-free, invocation-scoped posture-unavailable diagnostic. */
2657
+ function makePostureUnavailableDiagnostic() {
2658
+ // Deliberately carries no repoName/ticketKey/status so every degraded row's
2659
+ // diagnostic shares the same de-dup key (BAPI-377) and collapses to one line.
2660
+ return makeModelRoutingDiagnostic({
2661
+ kind: "posture-unavailable",
2662
+ message: "optimization posture unavailable; Quality routing retained",
2663
+ });
2664
+ }
2665
+ /**
2666
+ * Read and normalize the repo's `optimization_posture` config field in its own
2667
+ * total `try/catch`, entirely separate from {@link fetchDifficultyModelRoutingConfig}.
2668
+ * A posture read can never turn into that function's `{ ok: false }` result or
2669
+ * trigger the premium fallback — it only ever yields `"quality"` on any
2670
+ * unset/malformed/unreadable outcome, with a sanitized warning+diagnostic
2671
+ * attached when the outcome was NOT the ordinary unset case.
2672
+ */
2673
+ export async function resolveOptimizationPostureForStartTickets(access) {
2674
+ try {
2675
+ const raw = await fetchStartTicketsConfigField(access, "optimization_posture");
2676
+ if (raw === null || raw === undefined) {
2677
+ // Ordinary S1 defaulting — NULL/unset resolves to quality, no diagnostic.
2678
+ return { posture: "quality", degraded: false };
2679
+ }
2680
+ if (isOptimizationPosture(raw)) {
2681
+ return { posture: raw, degraded: false };
2682
+ }
2683
+ // Unknown/malformed non-null value.
2684
+ return {
2685
+ posture: "quality",
2686
+ degraded: true,
2687
+ warning: POSTURE_UNAVAILABLE_WARNING,
2688
+ diagnostic: makePostureUnavailableDiagnostic(),
2689
+ };
2690
+ }
2691
+ catch {
2692
+ // Structured fetch failure or thrown error — never leak exception text.
2693
+ return {
2694
+ posture: "quality",
2695
+ degraded: true,
2696
+ warning: POSTURE_UNAVAILABLE_WARNING,
2697
+ diagnostic: makePostureUnavailableDiagnostic(),
2698
+ };
2699
+ }
2700
+ }
2571
2701
  /** One bounded authenticated fetch; reject partial, unsafe, or stale wire data. */
2572
2702
  export async function fetchCodexModelCatalog(access) {
2573
2703
  let body;
@@ -2816,6 +2946,67 @@ async function applyPremiumFallbackToEligibleRows(deps, rows, isEligible, agent,
2816
2946
  const isCreatedRoutingEligible = (r) => r.status === "created" && !!r.path;
2817
2947
  /** Dry-run path: rows have no worktree path yet but are eligible for a preview. */
2818
2948
  const isDryRunRoutingEligible = (r) => r.status === "dry-run";
2949
+ /**
2950
+ * Pure resolver for the diagnostic's `adjustment` token. `none` for manual
2951
+ * routing (posture `not_evaluated_manual_override`) and for `quality`/`speed`;
2952
+ * the exact transition token — including the `cheap` floor — under
2953
+ * `maximize_subscription`. Total and exhaustive over {@link ModelTier}.
2954
+ */
2955
+ function resolveModelRoutingDecisionAdjustment(posture, baseline) {
2956
+ if (posture !== "maximize_subscription")
2957
+ return "none";
2958
+ switch (baseline) {
2959
+ case "premium":
2960
+ return "premium->basic";
2961
+ case "basic":
2962
+ return "basic->cheap";
2963
+ case "cheap":
2964
+ return "cheap->cheap_floor";
2965
+ }
2966
+ }
2967
+ /**
2968
+ * Format the exact deterministic diagnostic line. Inputs are restricted to
2969
+ * closed tier/posture/adjustment/source values plus an already-validated
2970
+ * alias, so raw command arguments, backend responses, URLs, headers, and
2971
+ * credentials cannot enter the line.
2972
+ */
2973
+ export function formatModelRoutingDecisionDiagnosticLine(input) {
2974
+ return (`[model-routing] source=${input.source} baseline=${input.baseline} ` +
2975
+ `posture=${input.posture} adjustment=${input.adjustment} effective=${input.effective} ` +
2976
+ `alias=${input.alias ?? "unresolved"}`);
2977
+ }
2978
+ /**
2979
+ * Best-effort emitter: calls the supplied stderr sink once and swallows any
2980
+ * logger exception so diagnostic emission can never affect routing.
2981
+ */
2982
+ function emitModelRoutingDecisionDiagnostic(errorLog, input) {
2983
+ try {
2984
+ errorLog(formatModelRoutingDecisionDiagnosticLine(input));
2985
+ }
2986
+ catch {
2987
+ // Diagnostic emission is best-effort and must never affect routing.
2988
+ }
2989
+ }
2990
+ /**
2991
+ * Emit one `manual_override` diagnostic per eligible row for the two manual
2992
+ * `--tier` routing paths (a recognized tier, or the malformed-value premium
2993
+ * fallback). `tier` is both the baseline and the effective tier — a manual
2994
+ * override is applied exactly as supplied, with posture never evaluated.
2995
+ */
2996
+ function emitManualOverrideDiagnostics(errorLog, routedRows, isEligible, tier) {
2997
+ for (const row of routedRows) {
2998
+ if (!isEligible(row))
2999
+ continue;
3000
+ emitModelRoutingDecisionDiagnostic(errorLog, {
3001
+ source: "manual_override",
3002
+ baseline: tier,
3003
+ posture: "not_evaluated_manual_override",
3004
+ adjustment: "none",
3005
+ effective: tier,
3006
+ alias: row.modelAlias ?? null,
3007
+ });
3008
+ }
3009
+ }
2819
3010
  /**
2820
3011
  * BAPI-642 — apply a single explicitly injected tier to every eligible row,
2821
3012
  * bypassing the per-ticket difficulty/tier HTTP lookup. The tier is still mapped
@@ -2864,7 +3055,15 @@ async function applyInjectedTierRoutingToEligibleRows(deps, rows, isEligible, ag
2864
3055
  * `--model`; Claude/Cursor retain their premium fallback. Non-eligible rows
2865
3056
  * pass through untouched, and routing failures never abort worktree creation.
2866
3057
  */
2867
- async function resolveModelRoutingForEligible(deps, rows, options, agent, isEligible) {
3058
+ async function resolveModelRoutingForEligible(deps, rows, options, agent, isEligible,
3059
+ /**
3060
+ * BAPI-1225: injected sink for the harness routing-diagnostic line, matching
3061
+ * the shape of the existing `modelRoutingWarningLog`/`errorLog` seams. Every
3062
+ * caller's production path already has a `console.error`-backed logger in
3063
+ * scope; a missing injection (e.g. an older test double) falls back to
3064
+ * `console.error` directly, never `console.log`/stdout.
3065
+ */
3066
+ errorLog = (message) => console.error(message)) {
2868
3067
  const eligible = rows.filter(isEligible);
2869
3068
  if (eligible.length === 0)
2870
3069
  return rows;
@@ -2907,98 +3106,276 @@ async function resolveModelRoutingForEligible(deps, rows, options, agent, isElig
2907
3106
  // difficulty/tier lookup — `fetchTicketModelTiersForRows` is NEVER called for
2908
3107
  // any injected value. A recognized tier is applied to every eligible row; a
2909
3108
  // malformed value (INJECTED_TIER_UNRESOLVED) fails open to the premium
2910
- // fallback. When the flag is absent (`injected === undefined`) the legacy
2911
- // difficulty-lookup path below runs unchanged.
3109
+ // fallback. When the flag is absent (`injected === undefined`) the
3110
+ // automatic-snapshot / difficulty-lookup path below runs.
3111
+ //
3112
+ // BAPI-1224: an explicit `--tier` is an operator-forced MANUAL override and is
3113
+ // applied exactly as supplied, below, with no posture downshifting — the
3114
+ // optimization posture is never even read on this path (it is resolved only
3115
+ // further down, immediately before the automatic-snapshot and per-ticket
3116
+ // difficulty-lookup paths).
2912
3117
  const injected = options.injectedTier;
2913
3118
  if (injected === INJECTED_TIER_UNRESOLVED) {
2914
3119
  const warning = "model routing: --tier was not one of cheap|basic|premium; defaulting to premium routing";
2915
3120
  const apply = await makePremiumFallbackApplier(deps, agent, overrides, catalog, access.repoName);
2916
- return rows.map((r) => (isEligible(r) ? apply(r, warning) : r));
3121
+ const routed = rows.map((r) => (isEligible(r) ? apply(r, warning) : r));
3122
+ // BAPI-1236: the malformed-value premium fallback is still a manual
3123
+ // override — never posture-evaluated — so it emits `manual_override` too.
3124
+ emitManualOverrideDiagnostics(errorLog, routed, isEligible, "premium");
3125
+ return routed;
2917
3126
  }
2918
3127
  if (injected) {
2919
- return applyInjectedTierRoutingToEligibleRows(deps, rows, isEligible, agent, overrides, injected, catalog, access.repoName);
2920
- }
2921
- const tierMap = await fetchTicketModelTiersForRows(access, eligible, options.maxParallel, isEligible);
2922
- // Lazily resolve+validate the premium fallback alias at most once for this run
2923
- // (the alias is identical across rows; cursor-agent validation spawns a probe).
2924
- let premiumApplier = null;
2925
- const applyPremiumFallback = async (row, warning, diagnostic) => {
2926
- if (!premiumApplier)
2927
- premiumApplier = await makePremiumFallbackApplier(deps, agent, overrides, catalog, access.repoName);
2928
- return premiumApplier(row, warning, diagnostic);
2929
- };
2930
- const out = [];
2931
- for (const row of rows) {
2932
- if (!isEligible(row)) {
2933
- out.push(row);
2934
- continue;
3128
+ const routed = await applyInjectedTierRoutingToEligibleRows(deps, rows, isEligible, agent, overrides, injected, catalog, access.repoName);
3129
+ emitManualOverrideDiagnostics(errorLog, routed, isEligible, injected);
3130
+ return routed;
3131
+ }
3132
+ // BAPI-1224/BAPI-1236: resolve the repo's optimization posture ONCE, shared by
3133
+ // both the automatic-snapshot path (BAPI-1236's `--auto-tier`, immediately
3134
+ // below) and the ordinary per-ticket difficulty-lookup path — it cannot
3135
+ // affect any earlier short-circuit (no-eligible-row, unsupported agent,
3136
+ // access/config failure, routing-disabled, or the manual injected-tier
3137
+ // override above). The resolver is total and never throws; a degraded read
3138
+ // fails safe to "quality" (no downshift) plus a sanitized, invocation-scoped
3139
+ // warning/diagnostic attached to `out` below, regardless of which branch
3140
+ // produced it.
3141
+ const postureResolution = await resolveOptimizationPostureForStartTickets(access);
3142
+ const posture = postureResolution.posture;
3143
+ // BAPI-1225: one deterministic, best-effort diagnostic line per repository-level
3144
+ // resolution, emitted exactly here — outside the per-ticket row loop below, so a
3145
+ // multi-ticket invocation still logs it once for this canonical repository. Uses
3146
+ // the SAME normalized `posture` value that decides the S5 downshift immediately
3147
+ // below; this never re-resolves configuration. The existing `postureResolution`
3148
+ // degraded-warning/diagnostic plumbing is untouched — this is an addition, not a
3149
+ // replacement.
3150
+ try {
3151
+ errorLog(formatOptimizationPostureResolvedLine(posture, access.repoName));
3152
+ }
3153
+ catch {
3154
+ // Diagnostic emission is best-effort and must never affect routing.
3155
+ }
3156
+ let out;
3157
+ const autoTier = options.autoTier;
3158
+ if (autoTier) {
3159
+ // BAPI-1236 — automatic-snapshot routing: a machine-carried raw tier from a
3160
+ // chained automation handoff (e.g. `/review-and-implement`'s `--auto-tier`).
3161
+ // Routed through the CURRENT session's posture EXACTLY ONCE — unlike the
3162
+ // manual override above, this path IS subject to `downshiftModelTier()`
3163
+ // under `maximize_subscription` — while skipping ONLY the redundant
3164
+ // per-ticket `fetchTicketModelTiersForRows()` lookup. The alias is
3165
+ // resolved+validated ONCE (identical for every row), matching the premium
3166
+ // fallback / injected-tier hoisting pattern above.
3167
+ const effectiveTier = posture === "maximize_subscription" ? downshiftModelTier(autoTier) : autoTier;
3168
+ const adjustment = resolveModelRoutingDecisionAdjustment(posture, autoTier);
3169
+ const lineageSuffix = posture === "maximize_subscription"
3170
+ ? `; auto-tier:${autoTier}->maximize:${effectiveTier}`
3171
+ : "";
3172
+ const alias = resolveRoutingAlias(agent, effectiveTier, overrides, catalog);
3173
+ let resolvedAlias = null;
3174
+ let aliasWarning = null;
3175
+ if (!alias) {
3176
+ aliasWarning = `model routing: no valid alias for tier=${effectiveTier}; using agent default`;
2935
3177
  }
2936
- const tierResult = tierMap.get(row.key);
2937
- if (!tierResult || !tierResult.ok) {
2938
- // Error reaching the per-ticket tier endpoint — default to premium/Opus,
2939
- // stamping the per-ticket tier-fetch diagnostic.
2940
- const warning = tierResult && !tierResult.ok
2941
- ? tierResult.warning
2942
- : `model routing: no tier resolved for ${row.key}; assuming hard ticket`;
2943
- const diagnostic = tierResult && !tierResult.ok
2944
- ? tierResult.diagnostic
2945
- : makeModelRoutingDiagnostic({
2946
- kind: "tier-fetch-unavailable",
3178
+ else {
3179
+ const validation = await validateResolvedModelAliasForAgent(deps, agent, alias);
3180
+ if (validation.ok) {
3181
+ resolvedAlias = validation.alias;
3182
+ }
3183
+ else {
3184
+ aliasWarning = validation.warning;
3185
+ }
3186
+ }
3187
+ const reason = resolvedAlias
3188
+ ? `auto-tier=${autoTier} model=${resolvedAlias} (posture=${posture})${lineageSuffix}`
3189
+ : `${aliasWarning}${lineageSuffix}`;
3190
+ out = rows.map((row) => {
3191
+ if (!isEligible(row))
3192
+ return row;
3193
+ const baseRow = {
3194
+ ...row,
3195
+ difficulty: null,
3196
+ modelTier: effectiveTier,
3197
+ modelAlias: resolvedAlias,
3198
+ modelRoutingSource: "automatic_snapshot",
3199
+ modelRoutingReason: reason,
3200
+ ...(agent.name === "codex" && resolvedAlias
3201
+ ? { modelRoutingDiagnostic: codexEntitlementDiagnostic(access.repoName) }
3202
+ : {}),
3203
+ };
3204
+ return aliasWarning ? appendSummaryRowWarning(baseRow, aliasWarning) : baseRow;
3205
+ });
3206
+ // BAPI-1236: one `automatic_snapshot` diagnostic per eligible row — the
3207
+ // carried baseline, the newly resolved posture, the exact adjustment token,
3208
+ // the calculated effective tier, and the validated alias (or `unresolved`).
3209
+ for (const row of rows) {
3210
+ if (!isEligible(row))
3211
+ continue;
3212
+ emitModelRoutingDecisionDiagnostic(errorLog, {
3213
+ source: "automatic_snapshot",
3214
+ baseline: autoTier,
3215
+ posture,
3216
+ adjustment,
3217
+ effective: effectiveTier,
3218
+ alias: resolvedAlias,
3219
+ });
3220
+ }
3221
+ }
3222
+ else {
3223
+ // Ordinary per-ticket difficulty-lookup path.
3224
+ const tierMap = await fetchTicketModelTiersForRows(access, eligible, options.maxParallel, isEligible);
3225
+ // Lazily resolve+validate the premium fallback alias at most once for this run
3226
+ // (the alias is identical across rows; cursor-agent validation spawns a probe).
3227
+ let premiumApplier = null;
3228
+ const applyPremiumFallback = async (row, warning, diagnostic) => {
3229
+ if (!premiumApplier)
3230
+ premiumApplier = await makePremiumFallbackApplier(deps, agent, overrides, catalog, access.repoName);
3231
+ return premiumApplier(row, warning, diagnostic);
3232
+ };
3233
+ const loopOut = [];
3234
+ for (const row of rows) {
3235
+ if (!isEligible(row)) {
3236
+ loopOut.push(row);
3237
+ continue;
3238
+ }
3239
+ const tierResult = tierMap.get(row.key);
3240
+ if (!tierResult || !tierResult.ok) {
3241
+ // Error reaching the per-ticket tier endpoint — default to premium/Opus,
3242
+ // stamping the per-ticket tier-fetch diagnostic.
3243
+ const warning = tierResult && !tierResult.ok
3244
+ ? tierResult.warning
3245
+ : `model routing: no tier resolved for ${row.key}; assuming hard ticket`;
3246
+ const diagnostic = tierResult && !tierResult.ok
3247
+ ? tierResult.diagnostic
3248
+ : makeModelRoutingDiagnostic({
3249
+ kind: "tier-fetch-unavailable",
3250
+ repoName: access.repoName,
3251
+ storeTarget: `bapi:${access.repoName}`,
3252
+ ticketKey: row.key,
3253
+ message: `no tier resolved for ${row.key}`,
3254
+ });
3255
+ if (agent.name === "codex") {
3256
+ loopOut.push(codexDefaultRows([row], () => true, warning, diagnostic)[0]);
3257
+ continue;
3258
+ }
3259
+ loopOut.push(await applyPremiumFallback(row, warning, diagnostic));
3260
+ continue;
3261
+ }
3262
+ const { difficulty, tier, source } = tierResult.value;
3263
+ if (!tier) {
3264
+ // Backend reported no usable difficulty — default to premium/Opus. (The
3265
+ // backend now returns a premium fallback itself, so this is defensive.)
3266
+ // BAPI-1224: this is the harness-LOCAL premium fallback, deliberately
3267
+ // exempt from maximize downshifting — this fail-safe-to-strong path must
3268
+ // stay strong regardless of posture, so `makePremiumFallbackApplier`
3269
+ // receives the literal "premium" tier unchanged.
3270
+ const baseRow = { ...row, difficulty: difficulty ?? null, modelTier: tier ?? null, modelRoutingSource: source };
3271
+ const warning = `model routing: ${row.key} resolved no tier (source=${source}); assuming hard ticket`;
3272
+ const diagnostic = makeModelRoutingDiagnostic({
3273
+ kind: "no-tier",
2947
3274
  repoName: access.repoName,
2948
3275
  storeTarget: `bapi:${access.repoName}`,
2949
3276
  ticketKey: row.key,
2950
- message: `no tier resolved for ${row.key}`,
3277
+ message: `${row.key} resolved no tier (source=${source})`,
2951
3278
  });
2952
- if (agent.name === "codex") {
2953
- out.push(codexDefaultRows([row], () => true, warning, diagnostic)[0]);
3279
+ if (agent.name === "codex") {
3280
+ loopOut.push(codexDefaultRows([baseRow], () => true, warning, diagnostic)[0]);
3281
+ continue;
3282
+ }
3283
+ loopOut.push(await applyPremiumFallback(baseRow, warning, diagnostic));
2954
3284
  continue;
2955
3285
  }
2956
- out.push(await applyPremiumFallback(row, warning, diagnostic));
2957
- continue;
2958
- }
2959
- const { difficulty, tier, source } = tierResult.value;
2960
- const baseRow = {
2961
- ...row,
2962
- difficulty: difficulty ?? null,
2963
- modelTier: tier ?? null,
2964
- modelRoutingSource: source,
2965
- };
2966
- if (!tier) {
2967
- // Backend reported no usable difficulty — default to premium/Opus. (The
2968
- // backend now returns a premium fallback itself, so this is defensive.)
2969
- const warning = `model routing: ${row.key} resolved no tier (source=${source}); assuming hard ticket`;
2970
- const diagnostic = makeModelRoutingDiagnostic({
2971
- kind: "no-tier",
2972
- repoName: access.repoName,
2973
- storeTarget: `bapi:${access.repoName}`,
2974
- ticketKey: row.key,
2975
- message: `${row.key} resolved no tier (source=${source})`,
2976
- });
2977
- if (agent.name === "codex") {
2978
- out.push(codexDefaultRows([baseRow], () => true, warning, diagnostic)[0]);
3286
+ // BAPI-1224 — automatic downshift call site: the backend-returned tier
3287
+ // (`difficultyTier`, retained regardless of whether its source is
3288
+ // `cached`/`computed`/`fallback` — a valid backend "fallback" tier is NOT
3289
+ // the harness-local fallback above and IS downshifted) is transformed to
3290
+ // `effectiveTier` only under `maximize_subscription`; every other posture
3291
+ // passes it through unchanged. The downshift happens here, before
3292
+ // `resolveRoutingAlias()`, so `applyInjectedTierRoutingToEligibleRows()`
3293
+ // and `makePremiumFallbackApplier()` — which call `resolveRoutingAlias()`
3294
+ // with an injected or literal "premium" tier — can never be downshifted
3295
+ // by accident. Claude and Cursor resolve `effectiveTier` through their
3296
+ // unmodified registry `tierModels`; Codex indexes the fetched
3297
+ // `catalog.models[effectiveTier]` — neither gains a maximize-specific
3298
+ // alias table. Copilot never reaches this loop at all: it has no
3299
+ // `AGENT_REGISTRY` entry and `isRunnableHostTarget()` rejects hosts
3300
+ // without a launch agent before an `AgentSpec` is ever resolved.
3301
+ const difficultyTier = tier;
3302
+ const effectiveTier = posture === "maximize_subscription" ? downshiftModelTier(difficultyTier) : difficultyTier;
3303
+ const adjustment = resolveModelRoutingDecisionAdjustment(posture, difficultyTier);
3304
+ const lineageSuffix = posture === "maximize_subscription"
3305
+ ? `; difficulty:${difficultyTier}->maximize:${effectiveTier}`
3306
+ : "";
3307
+ // BAPI-1236: stamp `difficulty_lookup` rather than copying the backend
3308
+ // cache/computation source (`cached`/`computed`/`fallback`) into the
3309
+ // summary's own routing-provenance field — that backend source is
3310
+ // preserved separately in `modelRoutingReason` below via `(source=...)`.
3311
+ const baseRow = {
3312
+ ...row,
3313
+ difficulty: difficulty ?? null,
3314
+ modelTier: effectiveTier,
3315
+ modelRoutingSource: "difficulty_lookup",
3316
+ };
3317
+ const alias = resolveRoutingAlias(agent, effectiveTier, overrides, catalog);
3318
+ if (!alias) {
3319
+ const warning = `model routing: no valid alias for tier=${effectiveTier}; using agent default`;
3320
+ loopOut.push(appendSummaryRowWarning({ ...baseRow, modelAlias: null, modelRoutingReason: warning + lineageSuffix }, warning));
3321
+ emitModelRoutingDecisionDiagnostic(errorLog, {
3322
+ source: "difficulty_lookup",
3323
+ baseline: difficultyTier,
3324
+ posture,
3325
+ adjustment,
3326
+ effective: effectiveTier,
3327
+ alias: null,
3328
+ });
2979
3329
  continue;
2980
3330
  }
2981
- out.push(await applyPremiumFallback(baseRow, warning, diagnostic));
2982
- continue;
2983
- }
2984
- const alias = resolveRoutingAlias(agent, tier, overrides, catalog);
2985
- if (!alias) {
2986
- const warning = `model routing: no valid alias for tier=${tier}; using agent default`;
2987
- out.push(appendSummaryRowWarning({ ...baseRow, modelAlias: null, modelRoutingReason: warning }, warning));
2988
- continue;
2989
- }
2990
- const validation = await validateResolvedModelAliasForAgent(deps, agent, alias);
2991
- if (!validation.ok) {
2992
- out.push(appendSummaryRowWarning({ ...baseRow, modelAlias: null, modelRoutingReason: validation.warning }, validation.warning));
2993
- continue;
3331
+ const validation = await validateResolvedModelAliasForAgent(deps, agent, alias);
3332
+ if (!validation.ok) {
3333
+ loopOut.push(appendSummaryRowWarning({ ...baseRow, modelAlias: null, modelRoutingReason: validation.warning + lineageSuffix }, validation.warning));
3334
+ emitModelRoutingDecisionDiagnostic(errorLog, {
3335
+ source: "difficulty_lookup",
3336
+ baseline: difficultyTier,
3337
+ posture,
3338
+ adjustment,
3339
+ effective: effectiveTier,
3340
+ alias: null,
3341
+ });
3342
+ continue;
3343
+ }
3344
+ loopOut.push({
3345
+ ...baseRow,
3346
+ modelAlias: validation.alias,
3347
+ modelRoutingReason: `tier=${effectiveTier} model=${validation.alias} (source=${source})${lineageSuffix}`,
3348
+ ...(agent.name === "codex"
3349
+ ? { modelRoutingDiagnostic: codexEntitlementDiagnostic(access.repoName) }
3350
+ : {}),
3351
+ });
3352
+ emitModelRoutingDecisionDiagnostic(errorLog, {
3353
+ source: "difficulty_lookup",
3354
+ baseline: difficultyTier,
3355
+ posture,
3356
+ adjustment,
3357
+ effective: effectiveTier,
3358
+ alias: validation.alias,
3359
+ });
2994
3360
  }
2995
- out.push({
2996
- ...baseRow,
2997
- modelAlias: validation.alias,
2998
- modelRoutingReason: `tier=${tier} model=${validation.alias} (source=${source})`,
2999
- ...(agent.name === "codex"
3000
- ? { modelRoutingDiagnostic: codexEntitlementDiagnostic(access.repoName) }
3001
- : {}),
3361
+ out = loopOut;
3362
+ }
3363
+ // BAPI-1224: a degraded posture read attaches ONE sanitized warning +
3364
+ // invocation-scoped diagnostic to every eligible row, without altering any
3365
+ // row's already-resolved tier/alias. A row that already carries a stronger
3366
+ // routing diagnostic (tier-fetch/no-tier/etc.) keeps it — the posture
3367
+ // diagnostic never overwrites a stronger one, so `selectPrimaryInvocation-
3368
+ // ModelRoutingDiagnostic` still surfaces the more actionable finding. Applies
3369
+ // uniformly to `out` regardless of whether the automatic-snapshot or the
3370
+ // ordinary difficulty-lookup branch produced it.
3371
+ if (postureResolution.degraded) {
3372
+ return out.map((r) => {
3373
+ if (!isEligible(r))
3374
+ return r;
3375
+ const withWarning = appendSummaryRowWarning(r, postureResolution.warning);
3376
+ return r.modelRoutingDiagnostic
3377
+ ? withWarning
3378
+ : { ...withWarning, modelRoutingDiagnostic: postureResolution.diagnostic };
3002
3379
  });
3003
3380
  }
3004
3381
  return out;
@@ -3008,8 +3385,8 @@ async function resolveModelRoutingForEligible(deps, rows, options, agent, isElig
3008
3385
  * entrypoint for the real spawn path. ALWAYS fail-open (see
3009
3386
  * {@link resolveModelRoutingForEligible}).
3010
3387
  */
3011
- export async function resolveModelRoutingForRows(deps, rows, options, agent) {
3012
- return resolveModelRoutingForEligible(deps, rows, options, agent, isCreatedRoutingEligible);
3388
+ export async function resolveModelRoutingForRows(deps, rows, options, agent, errorLog) {
3389
+ return resolveModelRoutingForEligible(deps, rows, options, agent, isCreatedRoutingEligible, errorLog);
3013
3390
  }
3014
3391
  /**
3015
3392
  * Dry-run variant: resolve routing for `dry-run` rows so `--dry-run` can preview
@@ -3019,8 +3396,8 @@ export async function resolveModelRoutingForRows(deps, rows, options, agent) {
3019
3396
  * difficulty the backend may compute and cache it (one LLM call) — exactly what
3020
3397
  * the subsequent real run would have done.
3021
3398
  */
3022
- export async function resolveModelRoutingForDryRun(deps, rows, options, agent) {
3023
- return resolveModelRoutingForEligible(deps, rows, options, agent, isDryRunRoutingEligible);
3399
+ export async function resolveModelRoutingForDryRun(deps, rows, options, agent, errorLog) {
3400
+ return resolveModelRoutingForEligible(deps, rows, options, agent, isDryRunRoutingEligible, errorLog);
3024
3401
  }
3025
3402
  /**
3026
3403
  * Format one concise routing decision line per ticket:
@@ -3032,6 +3409,11 @@ export async function resolveModelRoutingForDryRun(deps, rows, options, agent) {
3032
3409
  * `--tier`-driven implementation handoff. The ORDINARY implementation launch (a
3033
3410
  * plain `--workflow implement` with no `--tier`) appends NO label, so its line
3034
3411
  * stays byte-for-byte identical to the pre-feature format.
3412
+ *
3413
+ * BAPI-1236: `routing=auto-snapshot` is exclusive to a stored
3414
+ * `modelRoutingSource: "automatic_snapshot"` row (a `--auto-tier` machine
3415
+ * handoff) and is never applied to an `injected` row, so an automatic snapshot
3416
+ * can never be misread as a manual `--tier` override in the diagnostics.
3035
3417
  */
3036
3418
  export function formatModelRoutingLine(row, agent, context) {
3037
3419
  const difficulty = typeof row.difficulty === "number" ? String(row.difficulty) : "?";
@@ -3043,6 +3425,8 @@ export function formatModelRoutingLine(row, agent, context) {
3043
3425
  labels.push("phase=review");
3044
3426
  if (row.modelRoutingSource === "injected")
3045
3427
  labels.push("routing=injected");
3428
+ if (row.modelRoutingSource === "automatic_snapshot")
3429
+ labels.push("routing=auto-snapshot");
3046
3430
  return labels.length > 0 ? `${base} ${labels.join(" ")}` : base;
3047
3431
  }
3048
3432
  /** Stable de-dup key for an invocation-level routing diagnostic. */
@@ -3412,7 +3796,7 @@ export async function orchestrateStartTickets(deps, options, overrides = {}) {
3412
3796
  // BEFORE building the spawn command. Always fail-open: routing never aborts a
3413
3797
  // spawn — at worst a ticket runs on the agent's default model.
3414
3798
  const resolveRoutingFn = overrides.resolveModelRoutingForRows ?? resolveModelRoutingForRows;
3415
- const routed = await resolveRoutingFn(deps, conductorReady, options, agent);
3799
+ const routed = await resolveRoutingFn(deps, conductorReady, options, agent, overrides.modelRoutingWarningLog);
3416
3800
  // Per-ticket routing DECISION lines stay (one per spawnable ticket). The
3417
3801
  // noisy per-row routing WARNING emission is intentionally gone (BAPI-377): the
3418
3802
  // CLI now emits a single de-duplicated, actionable routing diagnostic per
@@ -3525,7 +3909,7 @@ export async function runStartTicketsCli(argv, overrides = {}) {
3525
3909
  // per-ticket routing decision line.
3526
3910
  const resolveDryRunRoutingFn = overrides.resolveModelRoutingForDryRun ?? resolveModelRoutingForDryRun;
3527
3911
  const dryRunRows = buildDryRunResults(options.keys, options.branchOverrides);
3528
- const routedDryRunRows = await resolveDryRunRoutingFn(deps, dryRunRows, options, agent);
3912
+ const routedDryRunRows = await resolveDryRunRoutingFn(deps, dryRunRows, options, agent, errorLog);
3529
3913
  const routedByKey = new Map(routedDryRunRows.map((r) => [r.key, r]));
3530
3914
  // Resolve the SAME worker MCP shim invocation the real provisioning would use
3531
3915
  // so the dry-run preview matches the `.mcp.json` that would actually be written.