@bridge_gpt/mcp-server 0.2.57 → 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 (90) hide show
  1. package/README.md +112 -113
  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 +5 -1
  6. package/build/codex-launch-scope.js +13 -2
  7. package/build/commands.generated.js +5 -6
  8. package/build/conductor/bridge-api-client.js +149 -0
  9. package/build/conductor/bring-up-facts.js +1 -1
  10. package/build/conductor/claude-hook.js +16 -16
  11. package/build/conductor/doctor.js +24 -19
  12. package/build/conductor/epic-implementer-cli.js +38 -5
  13. package/build/conductor/epic-reconcile.js +1 -1
  14. package/build/conductor/epic-runtime.js +10 -10
  15. package/build/conductor/errors.js +3 -3
  16. package/build/conductor/file-scope-guard.js +4 -4
  17. package/build/conductor/git-ci-types.js +1 -1
  18. package/build/conductor/git-inspection.js +2 -2
  19. package/build/conductor/install-doctor.js +6 -6
  20. package/build/conductor/merge-ledger.js +2 -2
  21. package/build/conductor/pr-ci-producer.js +6 -6
  22. package/build/conductor/pr-review-producer.js +2 -2
  23. package/build/conductor/producer-ledger.js +2 -0
  24. package/build/conductor/readiness-sections.js +7 -7
  25. package/build/conductor/readiness.js +2 -2
  26. package/build/conductor/recovery-cli.js +243 -12
  27. package/build/conductor/recovery-operations.js +56 -4
  28. package/build/conductor/repair-contract.js +38 -4
  29. package/build/conductor/spec-review-producer.js +6 -6
  30. package/build/conductor/store.js +10 -10
  31. package/build/conductor/supervisor-config.js +14 -14
  32. package/build/conductor/supervisor-ledger.js +2 -2
  33. package/build/conductor/supervisor-message-relay.js +2 -2
  34. package/build/conductor/worker-ledger-cli.js +19 -19
  35. package/build/conductor-bundle-artifacts.js +2 -2
  36. package/build/cursor-agent-progress.js +72 -0
  37. package/build/doctor.js +20 -296
  38. package/build/drive-epic.js +65 -71
  39. package/build/epic-implementer-bin.js +22 -17
  40. package/build/epic-implementer-claude-hook-bin.js +4 -2
  41. package/build/epic-implementer-env.js +155 -0
  42. package/build/epic-integration-pr.js +2 -2
  43. package/build/epic-shared/bridge-client.js +773 -0
  44. package/build/epic-shared/cut-protocol.js +392 -0
  45. package/build/executor/env.js +19 -8
  46. package/build/executor/heartbeat.js +1 -1
  47. package/build/executor/job-errors.js +3 -3
  48. package/build/executor/job-runner.js +1 -1
  49. package/build/executor/merge-job.js +35 -3
  50. package/build/executor/observation.js +1 -1
  51. package/build/executor/preflight.js +1 -1
  52. package/build/executor/prompt-spec.js +1 -1
  53. package/build/executor/reconcile-overlap-job.js +10 -130
  54. package/build/executor/reconcile-overlap-types.js +1 -1
  55. package/build/executor/runner.js +1 -1
  56. package/build/implement-epic/cli.js +1 -1
  57. package/build/index.js +187 -224
  58. package/build/init.js +20 -28
  59. package/build/install-bridge-conductor.js +7 -7
  60. package/build/install-bridge.js +203 -10
  61. package/build/install-doctor.js +1 -1
  62. package/build/mcp-host-entry-adapter.js +6 -0
  63. package/build/mcp-host-targets.js +2 -0
  64. package/build/mcp-invoke.js +5 -5
  65. package/build/mcp-profile.js +2 -2
  66. package/build/mcp-provisioning.js +2 -2
  67. package/build/mcp-server-invocation.js +3 -3
  68. package/build/merge-pull-request.js +15 -0
  69. package/build/model-routing-host.js +25 -0
  70. package/build/model-routing-preferences.js +99 -0
  71. package/build/pipelines.generated.js +11 -11
  72. package/build/plan-epic-implementer-eligibility.js +215 -0
  73. package/build/plan-execution-ledger.js +8 -0
  74. package/build/plane/member-roster.js +5 -5
  75. package/build/plane/supervisor.js +1 -1
  76. package/build/plane/types.js +5 -5
  77. package/build/readiness-check.js +1 -1
  78. package/build/readme.generated.js +1 -1
  79. package/build/review-tickets.js +4 -4
  80. package/build/setup-epic.js +8 -8
  81. package/build/start-tickets-conductor.js +32 -32
  82. package/build/start-tickets-prereqs.js +4 -9
  83. package/build/start-tickets.js +494 -98
  84. package/build/version.generated.js +3 -3
  85. package/docs/CONDUCTOR.md +69 -18
  86. package/docs/install/mcp-tool-integrations.md +2 -1
  87. package/package.json +2 -2
  88. package/pipelines/implement-ticket.json +2 -2
  89. package/pipelines/plan-epic.json +2 -2
  90. package/pipelines/review-ticket.json +1 -1
@@ -58,6 +58,7 @@ import { readFile, writeFile, mkdir, mkdtemp, stat, lstat, symlink, unlink, copy
58
58
  import os from "node:os";
59
59
  import { createHash } from "node:crypto";
60
60
  import path from "path";
61
+ import { fileURLToPath } from "node:url";
61
62
  import { validateHttpBaseUrl } from "./base-url.js";
62
63
  import { VERSION } from "./version.generated.js";
63
64
  import { resolveBapiCredentials, getPrimaryCredentialStorePath, } from "./credential-store.js";
@@ -76,7 +77,7 @@ import { TICKET_KEY_PATTERN as SHARED_TICKET_KEY_PATTERN, TICKET_KEY_USAGE_SUMMA
76
77
  // can never drift. `start-tickets.ts` imports VALUES from there; the prereqs
77
78
  // module imports only TYPES back, so the runtime graph stays acyclic.
78
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";
79
- 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";
80
81
  import { agentForPlatform, getHostTarget, getWorktreeHostTargets, isRunnableHostTarget, isHostPlatformId } from "./mcp-host-targets.js";
81
82
  import { resolveCodexLaunchScope } from "./codex-launch-scope.js";
82
83
  import { tmpdir } from "node:os";
@@ -134,7 +135,8 @@ export function getStartTicketsUsage() {
134
135
  " --host claude-code|cursor|codex|copilot-vscode|windsurf Current host when --agent is omitted; Claude Code, Cursor, and Codex can launch sessions",
135
136
  " Cursor with --auto launches headlessly after a bounded readiness check; without --auto it stays interactive for the review gate.",
136
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.",
137
- " --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.",
138
140
  " --rounds 1|2 Review round count forwarded to the review phase; review-only, valid only with --workflow review-and-implement",
139
141
  " --terminal terminal|iterm Override the macOS terminal app (default: auto-detect via $TERM_PROGRAM); honored on macOS only",
140
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",
@@ -142,16 +144,16 @@ export function getStartTicketsUsage() {
142
144
  " --base-branch BRANCH Cut new worktrees from BRANCH and refresh origin/BRANCH (default: main)",
143
145
  " --no-refresh-main Skip refresh of the configured base branch (default main); historical name retained for backward compatibility",
144
146
  " --max-parallel N Max worktrees to create concurrently (default: 3)",
145
- " --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.",
146
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.",
147
149
  " -h, --help Show this help",
148
150
  "",
149
151
  "Environment:",
150
152
  ` ${WORKTRUNK_BINARY_OVERRIDE_ENV} Override the Worktrunk executable name/path for nonstandard installs`,
151
153
  ` ${TMUX_SESSION_OVERRIDE_ENV} Override the tmux session-name prefix on Linux (default: ${DEFAULT_TMUX_SESSION_PREFIX})`,
152
- " BAPI_CONDUCTOR_GATE_NAME Conductor gate name for this run (default: implement-ticket)",
153
- " BAPI_CONDUCTOR_SUPERVISOR_MODE Conductor supervisor mode (default: auto when --auto, else interactive)",
154
- " 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",
155
157
  "",
156
158
  "Conductor observability (opt-in via --conductor):",
157
159
  " With --conductor, real Claude Code workers launched by start-tickets receive",
@@ -159,7 +161,7 @@ export function getStartTicketsUsage() {
159
161
  " local lifecycle events into the conductor ledger. Each such run mints one run_id",
160
162
  " and attributes worker events by worker_id, ticket key, and worktree path, and a",
161
163
  " supervisor peer tab is opened. Without --conductor none of this happens. Inspect",
162
- " 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",
163
165
  " only when --conductor is set.",
164
166
  "",
165
167
  "Prerequisites:",
@@ -206,6 +208,13 @@ export function parseStartTicketsArgs(argv) {
206
208
  // the parsed options); a `ModelTier` = usable override; INJECTED_TIER_UNRESOLVED
207
209
  // = supplied-but-malformed (fail open to premium). NEVER an error/throw.
208
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;
209
218
  const branchEntries = [];
210
219
  const keys = [];
211
220
  for (let i = 0; i < argv.length; i++) {
@@ -309,6 +318,39 @@ export function parseStartTicketsArgs(argv) {
309
318
  injectedTier = isModelTier(value) ? value : INJECTED_TIER_UNRESOLVED;
310
319
  continue;
311
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
+ }
312
354
  if (arg === "--terminal" || arg.startsWith("--terminal=")) {
313
355
  let value;
314
356
  if (arg.startsWith("--terminal=")) {
@@ -403,6 +445,18 @@ export function parseStartTicketsArgs(argv) {
403
445
  }
404
446
  keys.push(arg);
405
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
+ }
406
460
  // --- key validation ---
407
461
  if (keys.length === 0) {
408
462
  return {
@@ -507,6 +561,9 @@ export function parseStartTicketsArgs(argv) {
507
561
  // supplied, so an omitted flag leaves the legacy option shape untouched
508
562
  // (no own `injectedTier` key, not even `undefined`).
509
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 } : {}),
510
567
  },
511
568
  };
512
569
  }
@@ -1363,7 +1420,12 @@ export function buildAgentInvocationArgv(agent, prompt, modelAlias, launchArgsOr
1363
1420
  if (agent.name === "codex" && Array.isArray(launchArgsOrCursorWorkspace))
1364
1421
  argv.push(...launchArgsOrCursorWorkspace);
1365
1422
  if (agent.name === "cursor-agent" && typeof launchArgsOrCursorWorkspace === "string" && launchArgsOrCursorWorkspace) {
1366
- argv.push("-p", "--output-format", "text", "--trust", "--approve-mcps", "--force", "--workspace", launchArgsOrCursorWorkspace);
1423
+ // Cursor's text mode can buffer the entire headless turn. Route NDJSON
1424
+ // through our progress renderer, which prints assistant prose and tool
1425
+ // names without dumping tool arguments or results into the terminal.
1426
+ const progressScript = fileURLToPath(new URL("./cursor-agent-progress.js", import.meta.url));
1427
+ argv.splice(0, 1, "node", progressScript, "--", agent.command);
1428
+ argv.push("-p", "--output-format", "stream-json", "--trust", "--approve-mcps", "--force", "--workspace", launchArgsOrCursorWorkspace);
1367
1429
  }
1368
1430
  if (agent.supportsModelOverride &&
1369
1431
  typeof modelAlias === "string" &&
@@ -2197,7 +2259,7 @@ export function buildMcpProvisioningDeps(deps, bridgeApiBaseUrlOverride) {
2197
2259
  // conductor's own on-disk build (absolute-build-path primary, npm-channel
2198
2260
  // fallback) — never from process.cwd(). `nodeExecutable` is kept as "node"
2199
2261
  // here: Ticket 3 (P2) can later replace this single field with
2200
- // CONDUCTOR_NODE_PATH without re-plumbing argv construction.
2262
+ // EPIC_IMPLEMENTER_NODE_PATH without re-plumbing argv construction.
2201
2263
  mcpServerInvocation: resolveMcpShimInvocationForRuntime({
2202
2264
  moduleUrl: import.meta.url,
2203
2265
  nodeExecutable: "node",
@@ -2317,6 +2379,9 @@ export function formatModelRoutingDiagnosticLine(d) {
2317
2379
  return `model routing degraded: Codex model catalog failed validation. ${failOpen}`;
2318
2380
  case "codex-entitlement-unverified":
2319
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).");
2320
2385
  default:
2321
2386
  return `model routing degraded: ${d.message} ${failOpen}`;
2322
2387
  }
@@ -2562,6 +2627,77 @@ export async function fetchDifficultyModelRoutingConfig(access, includeOverrides
2562
2627
  };
2563
2628
  }
2564
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
+ }
2565
2701
  /** One bounded authenticated fetch; reject partial, unsafe, or stale wire data. */
2566
2702
  export async function fetchCodexModelCatalog(access) {
2567
2703
  let body;
@@ -2810,6 +2946,67 @@ async function applyPremiumFallbackToEligibleRows(deps, rows, isEligible, agent,
2810
2946
  const isCreatedRoutingEligible = (r) => r.status === "created" && !!r.path;
2811
2947
  /** Dry-run path: rows have no worktree path yet but are eligible for a preview. */
2812
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
+ }
2813
3010
  /**
2814
3011
  * BAPI-642 — apply a single explicitly injected tier to every eligible row,
2815
3012
  * bypassing the per-ticket difficulty/tier HTTP lookup. The tier is still mapped
@@ -2858,7 +3055,15 @@ async function applyInjectedTierRoutingToEligibleRows(deps, rows, isEligible, ag
2858
3055
  * `--model`; Claude/Cursor retain their premium fallback. Non-eligible rows
2859
3056
  * pass through untouched, and routing failures never abort worktree creation.
2860
3057
  */
2861
- 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)) {
2862
3067
  const eligible = rows.filter(isEligible);
2863
3068
  if (eligible.length === 0)
2864
3069
  return rows;
@@ -2901,98 +3106,276 @@ async function resolveModelRoutingForEligible(deps, rows, options, agent, isElig
2901
3106
  // difficulty/tier lookup — `fetchTicketModelTiersForRows` is NEVER called for
2902
3107
  // any injected value. A recognized tier is applied to every eligible row; a
2903
3108
  // malformed value (INJECTED_TIER_UNRESOLVED) fails open to the premium
2904
- // fallback. When the flag is absent (`injected === undefined`) the legacy
2905
- // 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).
2906
3117
  const injected = options.injectedTier;
2907
3118
  if (injected === INJECTED_TIER_UNRESOLVED) {
2908
3119
  const warning = "model routing: --tier was not one of cheap|basic|premium; defaulting to premium routing";
2909
3120
  const apply = await makePremiumFallbackApplier(deps, agent, overrides, catalog, access.repoName);
2910
- 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;
2911
3126
  }
2912
3127
  if (injected) {
2913
- return applyInjectedTierRoutingToEligibleRows(deps, rows, isEligible, agent, overrides, injected, catalog, access.repoName);
2914
- }
2915
- const tierMap = await fetchTicketModelTiersForRows(access, eligible, options.maxParallel, isEligible);
2916
- // Lazily resolve+validate the premium fallback alias at most once for this run
2917
- // (the alias is identical across rows; cursor-agent validation spawns a probe).
2918
- let premiumApplier = null;
2919
- const applyPremiumFallback = async (row, warning, diagnostic) => {
2920
- if (!premiumApplier)
2921
- premiumApplier = await makePremiumFallbackApplier(deps, agent, overrides, catalog, access.repoName);
2922
- return premiumApplier(row, warning, diagnostic);
2923
- };
2924
- const out = [];
2925
- for (const row of rows) {
2926
- if (!isEligible(row)) {
2927
- out.push(row);
2928
- 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`;
3177
+ }
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
+ }
2929
3186
  }
2930
- const tierResult = tierMap.get(row.key);
2931
- if (!tierResult || !tierResult.ok) {
2932
- // Error reaching the per-ticket tier endpoint — default to premium/Opus,
2933
- // stamping the per-ticket tier-fetch diagnostic.
2934
- const warning = tierResult && !tierResult.ok
2935
- ? tierResult.warning
2936
- : `model routing: no tier resolved for ${row.key}; assuming hard ticket`;
2937
- const diagnostic = tierResult && !tierResult.ok
2938
- ? tierResult.diagnostic
2939
- : makeModelRoutingDiagnostic({
2940
- kind: "tier-fetch-unavailable",
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",
2941
3274
  repoName: access.repoName,
2942
3275
  storeTarget: `bapi:${access.repoName}`,
2943
3276
  ticketKey: row.key,
2944
- message: `no tier resolved for ${row.key}`,
3277
+ message: `${row.key} resolved no tier (source=${source})`,
2945
3278
  });
2946
- if (agent.name === "codex") {
2947
- 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));
2948
3284
  continue;
2949
3285
  }
2950
- out.push(await applyPremiumFallback(row, warning, diagnostic));
2951
- continue;
2952
- }
2953
- const { difficulty, tier, source } = tierResult.value;
2954
- const baseRow = {
2955
- ...row,
2956
- difficulty: difficulty ?? null,
2957
- modelTier: tier ?? null,
2958
- modelRoutingSource: source,
2959
- };
2960
- if (!tier) {
2961
- // Backend reported no usable difficulty — default to premium/Opus. (The
2962
- // backend now returns a premium fallback itself, so this is defensive.)
2963
- const warning = `model routing: ${row.key} resolved no tier (source=${source}); assuming hard ticket`;
2964
- const diagnostic = makeModelRoutingDiagnostic({
2965
- kind: "no-tier",
2966
- repoName: access.repoName,
2967
- storeTarget: `bapi:${access.repoName}`,
2968
- ticketKey: row.key,
2969
- message: `${row.key} resolved no tier (source=${source})`,
2970
- });
2971
- if (agent.name === "codex") {
2972
- 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
+ });
2973
3329
  continue;
2974
3330
  }
2975
- out.push(await applyPremiumFallback(baseRow, warning, diagnostic));
2976
- continue;
2977
- }
2978
- const alias = resolveRoutingAlias(agent, tier, overrides, catalog);
2979
- if (!alias) {
2980
- const warning = `model routing: no valid alias for tier=${tier}; using agent default`;
2981
- out.push(appendSummaryRowWarning({ ...baseRow, modelAlias: null, modelRoutingReason: warning }, warning));
2982
- continue;
2983
- }
2984
- const validation = await validateResolvedModelAliasForAgent(deps, agent, alias);
2985
- if (!validation.ok) {
2986
- out.push(appendSummaryRowWarning({ ...baseRow, modelAlias: null, modelRoutingReason: validation.warning }, validation.warning));
2987
- 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
+ });
2988
3360
  }
2989
- out.push({
2990
- ...baseRow,
2991
- modelAlias: validation.alias,
2992
- modelRoutingReason: `tier=${tier} model=${validation.alias} (source=${source})`,
2993
- ...(agent.name === "codex"
2994
- ? { modelRoutingDiagnostic: codexEntitlementDiagnostic(access.repoName) }
2995
- : {}),
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 };
2996
3379
  });
2997
3380
  }
2998
3381
  return out;
@@ -3002,8 +3385,8 @@ async function resolveModelRoutingForEligible(deps, rows, options, agent, isElig
3002
3385
  * entrypoint for the real spawn path. ALWAYS fail-open (see
3003
3386
  * {@link resolveModelRoutingForEligible}).
3004
3387
  */
3005
- export async function resolveModelRoutingForRows(deps, rows, options, agent) {
3006
- 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);
3007
3390
  }
3008
3391
  /**
3009
3392
  * Dry-run variant: resolve routing for `dry-run` rows so `--dry-run` can preview
@@ -3013,8 +3396,8 @@ export async function resolveModelRoutingForRows(deps, rows, options, agent) {
3013
3396
  * difficulty the backend may compute and cache it (one LLM call) — exactly what
3014
3397
  * the subsequent real run would have done.
3015
3398
  */
3016
- export async function resolveModelRoutingForDryRun(deps, rows, options, agent) {
3017
- 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);
3018
3401
  }
3019
3402
  /**
3020
3403
  * Format one concise routing decision line per ticket:
@@ -3026,6 +3409,11 @@ export async function resolveModelRoutingForDryRun(deps, rows, options, agent) {
3026
3409
  * `--tier`-driven implementation handoff. The ORDINARY implementation launch (a
3027
3410
  * plain `--workflow implement` with no `--tier`) appends NO label, so its line
3028
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.
3029
3417
  */
3030
3418
  export function formatModelRoutingLine(row, agent, context) {
3031
3419
  const difficulty = typeof row.difficulty === "number" ? String(row.difficulty) : "?";
@@ -3037,6 +3425,8 @@ export function formatModelRoutingLine(row, agent, context) {
3037
3425
  labels.push("phase=review");
3038
3426
  if (row.modelRoutingSource === "injected")
3039
3427
  labels.push("routing=injected");
3428
+ if (row.modelRoutingSource === "automatic_snapshot")
3429
+ labels.push("routing=auto-snapshot");
3040
3430
  return labels.length > 0 ? `${base} ${labels.join(" ")}` : base;
3041
3431
  }
3042
3432
  /** Stable de-dup key for an invocation-level routing diagnostic. */
@@ -3406,7 +3796,7 @@ export async function orchestrateStartTickets(deps, options, overrides = {}) {
3406
3796
  // BEFORE building the spawn command. Always fail-open: routing never aborts a
3407
3797
  // spawn — at worst a ticket runs on the agent's default model.
3408
3798
  const resolveRoutingFn = overrides.resolveModelRoutingForRows ?? resolveModelRoutingForRows;
3409
- const routed = await resolveRoutingFn(deps, conductorReady, options, agent);
3799
+ const routed = await resolveRoutingFn(deps, conductorReady, options, agent, overrides.modelRoutingWarningLog);
3410
3800
  // Per-ticket routing DECISION lines stay (one per spawnable ticket). The
3411
3801
  // noisy per-row routing WARNING emission is intentionally gone (BAPI-377): the
3412
3802
  // CLI now emits a single de-duplicated, actionable routing diagnostic per
@@ -3427,14 +3817,20 @@ export async function orchestrateStartTickets(deps, options, overrides = {}) {
3427
3817
  let scoped = stampIndexScopeOnRows(routed, indexScope);
3428
3818
  if (agent.name === "codex") {
3429
3819
  const mcpDeps = buildMcpProvisioningDeps(deps);
3430
- scoped = await Promise.all(scoped.map(async (row) => {
3431
- if (row.status !== "created" || !row.path)
3432
- return row;
3820
+ // Scope resolution can refresh one shared Codex profile. Serialize these
3821
+ // reads and writes so sibling worktrees cannot observe a half-written update.
3822
+ const resolved = [];
3823
+ for (const row of scoped) {
3824
+ if (row.status !== "created" || !row.path) {
3825
+ resolved.push(row);
3826
+ continue;
3827
+ }
3433
3828
  const scope = await (overrides.resolveCodexScope ?? resolveCodexLaunchScope)(row.path, mcpDeps);
3434
- return scope.ok
3829
+ resolved.push(scope.ok
3435
3830
  ? { ...row, codexLaunchArgs: scope.argv }
3436
- : { ...row, status: "spawn-failed", error: scope.error };
3437
- }));
3831
+ : { ...row, status: "spawn-failed", error: scope.error });
3832
+ }
3833
+ scoped = resolved;
3438
3834
  }
3439
3835
  const rows = await spawnTabsFn(deps, scoped, terminal, platformConfig.config.buildAgentShellCommand);
3440
3836
  return { ok: true, rows };
@@ -3513,7 +3909,7 @@ export async function runStartTicketsCli(argv, overrides = {}) {
3513
3909
  // per-ticket routing decision line.
3514
3910
  const resolveDryRunRoutingFn = overrides.resolveModelRoutingForDryRun ?? resolveModelRoutingForDryRun;
3515
3911
  const dryRunRows = buildDryRunResults(options.keys, options.branchOverrides);
3516
- const routedDryRunRows = await resolveDryRunRoutingFn(deps, dryRunRows, options, agent);
3912
+ const routedDryRunRows = await resolveDryRunRoutingFn(deps, dryRunRows, options, agent, errorLog);
3517
3913
  const routedByKey = new Map(routedDryRunRows.map((r) => [r.key, r]));
3518
3914
  // Resolve the SAME worker MCP shim invocation the real provisioning would use
3519
3915
  // so the dry-run preview matches the `.mcp.json` that would actually be written.