@bridge_gpt/mcp-server 0.2.65 → 0.2.67

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 (132) hide show
  1. package/README.md +269 -82
  2. package/build/agent-capabilities/probes.js +15 -5
  3. package/build/agent-launchers/claude-executor-adapter.js +55 -34
  4. package/build/agent-launchers/executor-adapter.js +34 -4
  5. package/build/agents.generated.js +1 -1
  6. package/build/bounded-wait.js +67 -10
  7. package/build/bridge-config.js +104 -0
  8. package/build/codex-bridge-profile.js +10 -1
  9. package/build/commands.generated.js +12 -12
  10. package/build/conductor/bridge-api-client.js +372 -10
  11. package/build/conductor/bring-up-facts.js +1 -1
  12. package/build/conductor/doctor.js +3 -3
  13. package/build/conductor/epic-child-continuation.js +61 -34
  14. package/build/conductor/epic-implementer-cli.js +34 -6
  15. package/build/conductor/errors.js +4 -3
  16. package/build/conductor/guided-repair.js +327 -0
  17. package/build/conductor/hook-runtime-qualification.js +1 -1
  18. package/build/conductor/merge-execution-contract.js +27 -8
  19. package/build/conductor/merge-execution-eligibility.js +3 -2
  20. package/build/conductor/pr-ci-producer.js +1 -1
  21. package/build/conductor/readiness-cli.js +46 -9
  22. package/build/conductor/readiness-sections.js +41 -6
  23. package/build/conductor/readiness.js +204 -30
  24. package/build/conductor/recovery-cli.js +703 -20
  25. package/build/conductor/recovery-operations.js +392 -3
  26. package/build/conductor/repair-contract.js +112 -3
  27. package/build/conductor/repository-workflow-remedy.js +125 -0
  28. package/build/conductor/tools.js +2 -2
  29. package/build/conductor-bundle-artifacts.js +8 -0
  30. package/build/credential-store.js +5 -4
  31. package/build/diagnostic-text.js +36 -0
  32. package/build/docs.generated.js +8 -3
  33. package/build/doctor.js +70 -10
  34. package/build/drive-epic-key.js +6 -5
  35. package/build/drive-epic.js +479 -131
  36. package/build/epic-coherence-input.js +2 -1
  37. package/build/epic-implementer-bin.js +30 -26
  38. package/build/epic-implementer-bundle-cli.js +2 -2
  39. package/build/epic-launch-diagnostic.js +31 -0
  40. package/build/epic-launch-profile.js +44 -14
  41. package/build/executor/cli.js +3 -2
  42. package/build/executor/completion-contract.js +48 -5
  43. package/build/executor/completion-supervision.js +9 -0
  44. package/build/executor/correction-dispatch.js +60 -0
  45. package/build/executor/env.js +25 -0
  46. package/build/executor/heartbeat.js +1 -1
  47. package/build/executor/http-client.js +35 -0
  48. package/build/executor/integration-job.js +6 -1
  49. package/build/executor/job-errors.js +38 -4
  50. package/build/executor/job-log-registry.js +340 -73
  51. package/build/executor/job-runner.js +485 -74
  52. package/build/executor/job-types.js +8 -13
  53. package/build/executor/observation.js +6 -1
  54. package/build/executor/preflight.js +136 -10
  55. package/build/executor/prompt-spec.js +113 -9
  56. package/build/executor/required-work-ledger.js +6 -1
  57. package/build/executor/resume-pre-spawn.js +2 -0
  58. package/build/executor/runner.js +31 -2
  59. package/build/executor/service-unit.js +7 -1
  60. package/build/executor/stale-artifacts.js +5 -4
  61. package/build/executor/types.js +1 -0
  62. package/build/executor/watch-cli.js +35 -8
  63. package/build/executor/worker-command.js +43 -43
  64. package/build/executor/worker-finalization.js +161 -43
  65. package/build/executor/worker-guard-hook.js +152 -12
  66. package/build/executor/worker-log.js +137 -70
  67. package/build/executor/worktree-gc.js +30 -6
  68. package/build/executor/worktree.js +82 -1
  69. package/build/index.js +482 -660
  70. package/build/init.js +107 -4
  71. package/build/install-bridge-conductor.js +8 -6
  72. package/build/install-bridge.js +287 -23
  73. package/build/install-doctor.js +172 -30
  74. package/build/install-reexec.js +69 -7
  75. package/build/macos-terminal-permission-guidance.js +16 -0
  76. package/build/model-thresholds-tool.js +185 -0
  77. package/build/phase-context-git-probe.js +68 -0
  78. package/build/phase-result-artifacts.js +173 -5
  79. package/build/pipelines.generated.js +19 -25
  80. package/build/plane/alembic-head.js +12 -3
  81. package/build/plane/base-worktree-guard.js +147 -0
  82. package/build/plane/cli.js +217 -17
  83. package/build/plane/code-snapshot.js +468 -0
  84. package/build/plane/defaults.js +48 -3
  85. package/build/plane/endpoint.js +41 -0
  86. package/build/plane/manifest.js +120 -1
  87. package/build/plane/member-roster.js +94 -19
  88. package/build/plane/preflight.js +30 -2
  89. package/build/plane/process-cwd.js +67 -0
  90. package/build/plane/restart.js +542 -0
  91. package/build/plane/shutdown.js +31 -0
  92. package/build/plane/status.js +145 -6
  93. package/build/plane/supervisor.js +588 -177
  94. package/build/plane/types.js +54 -2
  95. package/build/plane/worker-exit-code.js +2 -0
  96. package/build/printed-remedy-commands.js +37 -0
  97. package/build/project-root.js +99 -0
  98. package/build/readiness-check.js +1 -1
  99. package/build/readme.generated.js +1 -1
  100. package/build/review-generation.js +22 -0
  101. package/build/review-tickets.js +156 -13
  102. package/build/setup-epic.js +224 -127
  103. package/build/sfcc/config.js +9 -0
  104. package/build/sfcc/credentials.js +100 -38
  105. package/build/sfcc/hostname.js +67 -0
  106. package/build/sfcc/permissions.js +17 -13
  107. package/build/sfcc/register.js +1 -1
  108. package/build/sfcc/setup-status.js +2 -0
  109. package/build/sfcc/tool-wrapper.js +41 -43
  110. package/build/sfcc/tools-configured.js +96 -0
  111. package/build/sfcc/write-grants.js +17 -11
  112. package/build/sfcc/write-result.js +4 -3
  113. package/build/sfcc/writes-site-preference.js +20 -8
  114. package/build/start-tickets.js +332 -79
  115. package/build/tool-error-envelope.js +61 -0
  116. package/build/upgrade-cli.js +109 -24
  117. package/build/version.generated.js +3 -3
  118. package/build/visual-diff.js +22 -1
  119. package/build/windows-npm-entry.js +103 -0
  120. package/build/worker-guard-hook-bin.js +3 -5
  121. package/build/worker-guard-hook-main.js +47 -0
  122. package/build/worktree-core.js +136 -9
  123. package/docs/EPIC-IMPLEMENTER-INSTALL.md +849 -0
  124. package/docs/EPIC-IMPLEMENTER.md +662 -0
  125. package/docs/install/mcp-tool-integrations.md +25 -15
  126. package/docs/install/sfcc-integration.md +21 -8
  127. package/package.json +1 -1
  128. package/pipelines/implement-ticket.json +1 -1
  129. package/pipelines/plan-epic.json +0 -5
  130. package/pipelines/review-ticket.json +1 -1
  131. package/docs/CONDUCTOR.md +0 -605
  132. package/pipelines/idea-to-pr.json +0 -49
@@ -54,6 +54,7 @@
54
54
  * unit-testable on Linux CI without spawning real commands or terminals.
55
55
  */
56
56
  import { execFile } from "child_process";
57
+ import { macosTerminalPermissionGuidance } from "./macos-terminal-permission-guidance.js";
57
58
  import { readFile, writeFile, mkdir, mkdtemp, stat, lstat, symlink, unlink, copyFile, readdir, rm, } from "fs/promises";
58
59
  import os from "node:os";
59
60
  import { createHash } from "node:crypto";
@@ -135,8 +136,8 @@ export function getStartTicketsUsage() {
135
136
  " --host claude-code|cursor|codex|copilot-vscode|windsurf Current host when --agent is omitted; Claude Code, Cursor, and Codex can launch sessions",
136
137
  " Cursor with --auto launches headlessly after a bounded readiness check; without --auto it stays interactive for the review gate.",
137
138
  " --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 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
+ " --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. With --workflow review-and-implement a valid value is also forwarded to the spawned command so the review tab and the fresh implementation session both run on it. Mutually exclusive with --auto-tier.",
140
+ " --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, unless the server reports configured thresholds). Not the strict-override mechanism — use --tier for a human override. Mutually exclusive with --tier.",
140
141
  " --rounds 1|2 Review round count forwarded to the review phase; review-only, valid only with --workflow review-and-implement",
141
142
  " --terminal terminal|iterm Override the macOS terminal app (default: auto-detect via $TERM_PROGRAM); honored on macOS only",
142
143
  " --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",
@@ -618,7 +619,9 @@ resumeMode = false, workflow = "implement", reviewRounds,
618
619
  // conductor runs always, and (BAPI-801/BAPI-847) for every plain run whose base
619
620
  // resolved, `main` included — and threaded (BAPI-593) into the spawned workflow
620
621
  // command.
621
- baseBranch, bridgeApiBaseUrlOverride) {
622
+ baseBranch, bridgeApiBaseUrlOverride,
623
+ // BAPI-1365: a recognized operator `--tier`, forwarded to the review prompt.
624
+ manualTier) {
622
625
  if (!isSupportedStartTicketsPlatform(deps.platform)) {
623
626
  return { ok: false, error: unsupportedPlatformMessage(deps.platform) };
624
627
  }
@@ -639,7 +642,7 @@ baseBranch, bridgeApiBaseUrlOverride) {
639
642
  // falls back to the basename-derived repo name (the 403 root cause), and
640
643
  // (BAPI-586/BAPI-801) the run base so the spawned worker opens its PR
641
644
  // against it. The base assignment stays outermost.
642
- buildAgentShellCommand: (key, worktreePath, modelAlias, launchArgs) => prependBaseBranchEnvAssignment(prependRepoNameEnvAssignment(buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, launchArgs, bridgeApiBaseUrl), repoName, platform), prBaseBranch, platform),
645
+ buildAgentShellCommand: (key, worktreePath, modelAlias, launchArgs) => prependBaseBranchEnvAssignment(prependRepoNameEnvAssignment(buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, launchArgs, bridgeApiBaseUrl, manualTier), repoName, platform), prBaseBranch, platform),
643
646
  spawnTerminalTab: deps.spawnTerminalTab,
644
647
  },
645
648
  };
@@ -1071,6 +1074,19 @@ export async function refreshBaseBranch(deps, options) {
1071
1074
  // `<baseBranch>` is checked out somewhere: fast-forward it in place. git refuses to
1072
1075
  // force-move a checked-out branch ref, so we must go through merge.
1073
1076
  if (basePath) {
1077
+ // BAPI-1362: a live plane member may be running out of this very checkout.
1078
+ // Fail closed — an unreadable or ambiguous record protects it. When
1079
+ // protected, nothing local is moved: the caller cuts from the fetched
1080
+ // `origin/<base>` SHA through the same fetch-only resolution the
1081
+ // non-mutating path uses.
1082
+ const decision = await evaluateGuardSafely(deps, basePath);
1083
+ if (decision.kind === "protect") {
1084
+ (deps.baseWorktreeGuardWarningLog ?? defaultGuardWarningLog)(formatBaseWorktreeGuardWarning(decision, baseBranch));
1085
+ const resolved = await fetchAndResolveBaseSha(deps, baseBranch);
1086
+ if (!resolved.ok)
1087
+ return { ok: false, error: resolved.error };
1088
+ return { ok: true, protectedBaseSha: resolved.base_sha };
1089
+ }
1074
1090
  const merge = await deps.runCommand("git", ["merge", "--ff-only", originRef], { cwd: basePath });
1075
1091
  if (!commandSucceeded(merge)) {
1076
1092
  return {
@@ -1102,6 +1118,25 @@ export async function refreshBaseBranch(deps, options) {
1102
1118
  }
1103
1119
  return { ok: true };
1104
1120
  }
1121
+ function defaultGuardWarningLog(message) {
1122
+ // stderr, never stdout: stdout carries the workflow's normal output.
1123
+ process.stderr.write(`${message}\n`);
1124
+ }
1125
+ /** Run the guard; any failure of the guard itself is a `protect` (fail closed). */
1126
+ async function evaluateGuardSafely(deps, basePath) {
1127
+ try {
1128
+ if (deps.evaluateBaseWorktreeGuard)
1129
+ return await deps.evaluateBaseWorktreeGuard(basePath);
1130
+ return await evaluateBaseWorktreeGuard(basePath, await createBaseWorktreeGuardDeps());
1131
+ }
1132
+ catch {
1133
+ return {
1134
+ kind: "protect",
1135
+ reason: "manifest-unreadable",
1136
+ manifestLocation: ".bridge/plane/plane.json",
1137
+ };
1138
+ }
1139
+ }
1105
1140
  /**
1106
1141
  * Compatibility wrapper around {@link refreshBaseBranch} that preserves the
1107
1142
  * historical `refreshMainBranch(deps, { refreshMain })` signature for callers
@@ -1194,6 +1229,7 @@ async function emitClaudeReviewWorkflowDriftAdvisory(deps, baseBranch, overrides
1194
1229
  // import the VALUES here (so internal uses keep working) and re-export the same
1195
1230
  // references so existing importers of `./start-tickets.js` (index.ts,
1196
1231
  // review-tickets.ts) and the pinned start-tickets tests are unaffected.
1232
+ import { evaluateBaseWorktreeGuard, createBaseWorktreeGuardDeps, formatBaseWorktreeGuardWarning, } from "./plane/base-worktree-guard.js";
1197
1233
  import { validateBranchName, fetchAndResolveBaseSha,
1198
1234
  // BAPI-941: both live in the same leaf module so the interactive refresh path
1199
1235
  // and the non-mutating conductor path share one drift implementation.
@@ -1338,8 +1374,9 @@ export async function resumeWorktrees(deps, options) {
1338
1374
  * E1/F3b proved a clean TEXTUAL merge can still hide a SEMANTIC break (branch A
1339
1375
  * adds a helper; branch B adds a contract test over all helpers), which only the
1340
1376
  * FULL suite catches — so this instruction requires the worker to run the full
1341
- * project test suite (the same gate the advisory pre-push hook enforces) and only
1342
- * push/complete on green, escalating otherwise.
1377
+ * project test suite (every unit suite the project defines, the same suites its
1378
+ * pull request CI runs) and only push/complete on green, escalating otherwise.
1379
+ * The wording stays project-neutral because this CLI runs in other repositories.
1343
1380
  *
1344
1381
  * Single line (no newlines, no `;`, no quotes/apostrophes) so it stays safe when
1345
1382
  * single-quoted into a shell command and embedded in terminal-launcher args.
@@ -1350,8 +1387,8 @@ export function buildResumeModeRemediationFinalizeInstruction() {
1350
1387
  "the current base branch and resolve the merge conflicts. A clean textual merge can " +
1351
1388
  "still break behavior, so inspect for semantic conflicts even when there are no textual " +
1352
1389
  "conflict markers. Before you push or mark the ticket complete, run the full test suite " +
1353
- "for the project (the full unit suite, the same gate enforced by the advisory pre-push " +
1354
- "hook described in CLAUDE.md under the CI cost model and advisory pre-push hook section) " +
1390
+ "for the project (the full unit suite: every unit test suite the project defines, the " +
1391
+ "same suites its pull request CI runs) " +
1355
1392
  "and do not rely on targeted subsets as your only verification. Push and mark the ticket " +
1356
1393
  "complete only after the full suite is green. If you cannot make the full suite pass, " +
1357
1394
  "report the ticket blocked and escalate rather than pushing a green-looking but broken merge.");
@@ -1384,6 +1421,8 @@ export function buildAgentPrompt(key, opts = {}) {
1384
1421
  // Review-only arguments (`--rounds`, non-default `--base-branch`) must never
1385
1422
  // leak into the legacy `/implement-ticket` prompt.
1386
1423
  if (workflow === "review-and-implement") {
1424
+ if (opts.manualTier !== undefined)
1425
+ command += ` --tier=${opts.manualTier}`;
1387
1426
  if (opts.agentName === "cursor-agent")
1388
1427
  command += " --agent=cursor-agent";
1389
1428
  if (opts.reviewRounds !== undefined) {
@@ -1456,13 +1495,13 @@ export function buildAgentInvocation(agent, prompt, quote, modelAlias, launchArg
1456
1495
  }
1457
1496
  }
1458
1497
  /** POSIX agent shell command: `cd '<path>' && <agent> [--model '<alias>'] '<prompt>'`. */
1459
- export function buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch, launchArgs = [], bridgeApiBaseUrl) {
1460
- const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, agentName: agent.name }), (p) => `'${shSquoteInner(p)}'`, modelAlias, agent.name === "cursor-agent" && autoApprove ? worktreePath : launchArgs);
1498
+ export function buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch, launchArgs = [], bridgeApiBaseUrl, manualTier) {
1499
+ const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, agentName: agent.name, manualTier }), (p) => `'${shSquoteInner(p)}'`, modelAlias, agent.name === "cursor-agent" && autoApprove ? worktreePath : launchArgs);
1461
1500
  return `cd '${shSquoteInner(worktreePath)}' && ${prependBridgeApiBaseUrlEnvAssignment(invocation, bridgeApiBaseUrl, "darwin")}`;
1462
1501
  }
1463
1502
  /** PowerShell agent shell command: `Set-Location -LiteralPath '<path>'; <agent> [--model '<alias>'] '<prompt>'`. */
1464
- export function buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch, launchArgs = [], bridgeApiBaseUrl) {
1465
- const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, agentName: agent.name }), powershellSquote, modelAlias, agent.name === "cursor-agent" && autoApprove ? worktreePath : launchArgs);
1503
+ export function buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch, launchArgs = [], bridgeApiBaseUrl, manualTier) {
1504
+ const invocation = buildAgentInvocation(agent, buildAgentPrompt(key, { autoApprove, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, agentName: agent.name, manualTier }), powershellSquote, modelAlias, agent.name === "cursor-agent" && autoApprove ? worktreePath : launchArgs);
1466
1505
  return `Set-Location -LiteralPath ${powershellSquote(worktreePath)}; ${prependBridgeApiBaseUrlEnvAssignment(invocation, bridgeApiBaseUrl, "win32")}`;
1467
1506
  }
1468
1507
  /**
@@ -1473,10 +1512,10 @@ export function buildPowerShellAgentShellCommand(agent, key, worktreePath, autoA
1473
1512
  * injected as `--model` at the spawn boundary. `conductorEnabled` appends the
1474
1513
  * BAPI-586 PR-base contract instruction to the prompt (opt-in via `--conductor`).
1475
1514
  */
1476
- export function buildAgentShellCommand(agent, key, worktreePath, platform = "darwin", autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch, launchArgs = [], bridgeApiBaseUrl) {
1515
+ export function buildAgentShellCommand(agent, key, worktreePath, platform = "darwin", autoApprove = false, modelAlias, conductorEnabled = false, resumeMode = false, workflow = "implement", reviewRounds, baseBranch, launchArgs = [], bridgeApiBaseUrl, manualTier) {
1477
1516
  if (platform === "win32")
1478
- return buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, launchArgs, bridgeApiBaseUrl);
1479
- return buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, launchArgs, bridgeApiBaseUrl);
1517
+ return buildPowerShellAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, launchArgs, bridgeApiBaseUrl, manualTier);
1518
+ return buildPosixAgentShellCommand(agent, key, worktreePath, autoApprove, modelAlias, conductorEnabled, resumeMode, workflow, reviewRounds, baseBranch, launchArgs, bridgeApiBaseUrl, manualTier);
1480
1519
  }
1481
1520
  /**
1482
1521
  * Build the shell command run inside a spawned tab/session for an ARBITRARY
@@ -2104,7 +2143,7 @@ export function buildDryRunResults(keys, overrides) {
2104
2143
  * PowerShell on Windows, `wt` + POSIX on macOS/Linux, and a non-throwing `wt` +
2105
2144
  * POSIX fallback for unsupported platforms.
2106
2145
  */
2107
- export function getDryRunPlatformDetails(agent, platform = process.platform, env = process.env, autoApprove = false, conductorEnabled = false, repoName = null, workflow = "implement", reviewRounds, baseBranch) {
2146
+ export function getDryRunPlatformDetails(agent, platform = process.platform, env = process.env, autoApprove = false, conductorEnabled = false, repoName = null, workflow = "implement", reviewRounds, baseBranch, manualTier) {
2108
2147
  return {
2109
2148
  worktrunkBinary: resolveWorktrunkBinary(platform, env),
2110
2149
  // The builder accepts an optional resolved modelAlias; the dry-run caller
@@ -2117,7 +2156,7 @@ export function getDryRunPlatformDetails(agent, platform = process.platform, env
2117
2156
  // buildAgentShellCommand/buildAgentPrompt path as a real spawn. A Codex
2118
2157
  // dry-run has no created worktree to bind, so its preview omits the
2119
2158
  // process-local -C/-c MCP scope that the real spawn resolves later.
2120
- buildAgentShellCommand: (key, worktreePath, modelAlias, launchArgs) => prependBaseBranchEnvAssignment(prependRepoNameEnvAssignment(buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove, modelAlias, conductorEnabled, false, workflow, reviewRounds, baseBranch, launchArgs, resolveSpawnBridgeApiBaseUrl(env)), repoName, platform), resolvePrBaseBranchEnvValue(conductorEnabled, baseBranch), platform),
2159
+ buildAgentShellCommand: (key, worktreePath, modelAlias, launchArgs) => prependBaseBranchEnvAssignment(prependRepoNameEnvAssignment(buildAgentShellCommand(agent, key, worktreePath, platform, autoApprove, modelAlias, conductorEnabled, false, workflow, reviewRounds, baseBranch, launchArgs, resolveSpawnBridgeApiBaseUrl(env), manualTier), repoName, platform), resolvePrBaseBranchEnvValue(conductorEnabled, baseBranch), platform),
2121
2160
  };
2122
2161
  }
2123
2162
  /**
@@ -2156,8 +2195,8 @@ export function buildDryRunMcpProvisioningLines(worktreePath, platform = process
2156
2195
  * the secret-free MCP provisioning preview. Pure platform formatting only — no
2157
2196
  * preflight, no routing failures.
2158
2197
  */
2159
- export function buildDryRunDetailLines(agent, key, branch, platform = process.platform, env = process.env, baseBranch = "main", autoApprove = false, modelAlias = null, conductorEnabled = false, repoName = null, mcpServerInvocation, workflow = "implement", reviewRounds) {
2160
- const { worktrunkBinary, buildAgentShellCommand: build } = getDryRunPlatformDetails(agent, platform, env, autoApprove, conductorEnabled, repoName, workflow, reviewRounds, baseBranch);
2198
+ export function buildDryRunDetailLines(agent, key, branch, platform = process.platform, env = process.env, baseBranch = "main", autoApprove = false, modelAlias = null, conductorEnabled = false, repoName = null, mcpServerInvocation, workflow = "implement", reviewRounds, manualTier) {
2199
+ const { worktrunkBinary, buildAgentShellCommand: build } = getDryRunPlatformDetails(agent, platform, env, autoApprove, conductorEnabled, repoName, workflow, reviewRounds, baseBranch, manualTier);
2161
2200
  const wtArgs = buildWtSwitchArgs(branch, false, baseBranch);
2162
2201
  const agentInvocation = build(key, "<worktree-path>", modelAlias);
2163
2202
  // BAPI-801/BAPI-847: one concise secondary detail naming the PR base, emitted
@@ -2382,6 +2421,12 @@ export function formatModelRoutingDiagnosticLine(d) {
2382
2421
  case "posture-unavailable":
2383
2422
  return ("model routing: optimization posture was unavailable; Quality routing was retained " +
2384
2423
  "(reason=optimization-posture-unavailable).");
2424
+ case "review-consumer-unrecognized":
2425
+ return (`model routing: the Bridge API server did not recognize the review consumer${ticketSuffix} ` +
2426
+ "(reason=consumer-unrecognized); the review tab uses the premium fallback. Upgrade the server.");
2427
+ case "review-threshold-legacy":
2428
+ return (`model routing: review thresholds are not configured${ticketSuffix} ` +
2429
+ "(reason=legacy); the review tab uses the premium fallback. Configure review model-tier thresholds.");
2385
2430
  default:
2386
2431
  return `model routing degraded: ${d.message} ${failOpen}`;
2387
2432
  }
@@ -2749,14 +2794,32 @@ export async function fetchCodexModelCatalog(access) {
2749
2794
  return invalid();
2750
2795
  return { ok: true, catalog: { models: models, revision } };
2751
2796
  }
2797
+ /**
2798
+ * True only when stored thresholds decided the tier (`configured` or
2799
+ * `anomaly`). The merged resolver maps every `anomaly` tier to `premium` as a
2800
+ * fail-safe, so a threshold-decided tier is final and must never be downshifted.
2801
+ *
2802
+ * A `source: "fallback"` response is never threshold-decided: the server could
2803
+ * not obtain a difficulty and assumed premium, even though it still reports the
2804
+ * stored thresholds' mode. Treating it as decided would give review tabs a
2805
+ * premium fallback and skip the maximize downshift for a guess.
2806
+ */
2807
+ export function isThresholdDecidedTierResponse(value) {
2808
+ if (value.source === "fallback")
2809
+ return false;
2810
+ return value.thresholdMode === "configured" || value.thresholdMode === "anomaly";
2811
+ }
2752
2812
  /**
2753
2813
  * Fetch the coarse tier for one ticket with a bounded timeout. Never throws to
2754
2814
  * the batch orchestrator: failures/timeouts return a structured warning plus a
2755
2815
  * classified diagnostic that preserves the ticket key in metadata.
2756
2816
  */
2757
- export async function fetchTicketModelTierForStartTickets(access, ticket) {
2817
+ export async function fetchTicketModelTierForStartTickets(access, ticket, fetchOptions = {}) {
2758
2818
  try {
2759
- const url = buildStartTicketsJiraUrl(access.baseUrl, `/tickets/${encodeURIComponent(ticket)}/model-tier`, { repo_name: access.repoName });
2819
+ const params = { repo_name: access.repoName };
2820
+ if (fetchOptions.consumer)
2821
+ params.consumer = fetchOptions.consumer;
2822
+ const url = buildStartTicketsJiraUrl(access.baseUrl, `/tickets/${encodeURIComponent(ticket)}/model-tier`, params);
2760
2823
  const body = await fetchJsonWithTimeout(url, startTicketsGetHeaders(access), TICKET_MODEL_TIER_FETCH_TIMEOUT_MS);
2761
2824
  const obj = (body ?? {});
2762
2825
  const tier = isModelTier(obj.tier) ? obj.tier : null;
@@ -2764,7 +2827,17 @@ export async function fetchTicketModelTierForStartTickets(access, ticket) {
2764
2827
  const source = obj.source === "cached" || obj.source === "computed" || obj.source === "fallback"
2765
2828
  ? obj.source
2766
2829
  : "fallback";
2767
- return { ok: true, value: { difficulty, tier, source } };
2830
+ const value = { difficulty, tier, source };
2831
+ // BAPI-1343: additive fields are attached only when the server sent them,
2832
+ // so an older server's response parses to exactly the pre-feature shape.
2833
+ if (obj.threshold_mode !== undefined && obj.threshold_mode !== null) {
2834
+ value.thresholdMode =
2835
+ obj.threshold_mode === "configured" || obj.threshold_mode === "anomaly" ? obj.threshold_mode : "legacy";
2836
+ }
2837
+ if (obj.consumer === "review" || obj.consumer === "implementation") {
2838
+ value.consumer = obj.consumer;
2839
+ }
2840
+ return { ok: true, value };
2768
2841
  }
2769
2842
  catch (err) {
2770
2843
  return {
@@ -2779,15 +2852,23 @@ export async function fetchTicketModelTierForStartTickets(access, ticket) {
2779
2852
  };
2780
2853
  }
2781
2854
  }
2855
+ /**
2856
+ * Fetch tiers for ticket keys with bounded parallelism (capped at 3). One
2857
+ * ticket's failure never aborts the others; results are keyed by ticket. The
2858
+ * shared bounded fetch behind both `start-tickets` and `/review-tickets`.
2859
+ */
2860
+ export async function fetchTicketModelTiersForKeys(access, keys, maxParallel, fetchOptions = {}) {
2861
+ const limit = Math.min(maxParallel, 3);
2862
+ const results = await runWithConcurrency(keys, limit, (key) => fetchTicketModelTierForStartTickets(access, key, fetchOptions).then((res) => [key, res]));
2863
+ return new Map(results);
2864
+ }
2782
2865
  /**
2783
2866
  * Fetch tiers for the still-spawnable rows with bounded parallelism (capped at
2784
2867
  * 3). One ticket's failure never aborts the others; results are keyed by ticket.
2785
2868
  */
2786
- export async function fetchTicketModelTiersForRows(access, rows, maxParallel, isEligible = (r) => r.status === "created" && !!r.path) {
2869
+ export async function fetchTicketModelTiersForRows(access, rows, maxParallel, isEligible = (r) => r.status === "created" && !!r.path, fetchOptions = {}) {
2787
2870
  const eligible = rows.filter(isEligible);
2788
- const limit = Math.min(maxParallel, 3);
2789
- const results = await runWithConcurrency(eligible, limit, (row) => fetchTicketModelTierForStartTickets(access, row.key).then((res) => [row.key, res]));
2790
- return new Map(results);
2871
+ return fetchTicketModelTiersForKeys(access, eligible.map((row) => row.key), maxParallel, fetchOptions);
2791
2872
  }
2792
2873
  /**
2793
2874
  * Parse a model-list command's stdout into a set of advertised model aliases.
@@ -2874,7 +2955,15 @@ export function applyDefaultModelRoutingMetadata(row, reason, diagnostic) {
2874
2955
  ...(diagnostic ? { modelRoutingDiagnostic: diagnostic } : {}),
2875
2956
  };
2876
2957
  }
2877
- function resolveRoutingAlias(agent, tier, overrides, catalog) {
2958
+ /**
2959
+ * Map a coarse tier to the agent's alias: the Codex authenticated catalog for
2960
+ * `codex`, otherwise the agent registry plus per-repo
2961
+ * `difficulty_model_tier_overrides`. Exported (BAPI-1343) so `/review-tickets`
2962
+ * maps review-threshold tiers exactly as `start-tickets` does, without copying
2963
+ * the registry or catalog rules. The result still needs
2964
+ * {@link validateResolvedModelAliasForAgent} before it reaches a shell.
2965
+ */
2966
+ export function resolveRoutingAlias(agent, tier, overrides, catalog) {
2878
2967
  return agent.name === "codex"
2879
2968
  ? catalog?.models[tier] ?? null
2880
2969
  : resolveModelAlias(agent, tier, overrides);
@@ -2951,8 +3040,12 @@ const isDryRunRoutingEligible = (r) => r.status === "dry-run";
2951
3040
  * routing (posture `not_evaluated_manual_override`) and for `quality`/`speed`;
2952
3041
  * the exact transition token — including the `cheap` floor — under
2953
3042
  * `maximize_subscription`. Total and exhaustive over {@link ModelTier}.
3043
+ * BAPI-1343: `none` whenever a `threshold` marker is supplied, because a
3044
+ * threshold-decided tier is final and is never downshifted.
2954
3045
  */
2955
- function resolveModelRoutingDecisionAdjustment(posture, baseline) {
3046
+ function resolveModelRoutingDecisionAdjustment(posture, baseline, threshold) {
3047
+ if (threshold)
3048
+ return "none";
2956
3049
  if (posture !== "maximize_subscription")
2957
3050
  return "none";
2958
3051
  switch (baseline) {
@@ -2973,7 +3066,65 @@ function resolveModelRoutingDecisionAdjustment(posture, baseline) {
2973
3066
  export function formatModelRoutingDecisionDiagnosticLine(input) {
2974
3067
  return (`[model-routing] source=${input.source} baseline=${input.baseline} ` +
2975
3068
  `posture=${input.posture} adjustment=${input.adjustment} effective=${input.effective} ` +
2976
- `alias=${input.alias ?? "unresolved"}`);
3069
+ `alias=${input.alias ?? "unresolved"}` +
3070
+ (input.threshold ? ` threshold=${input.threshold}` : "") +
3071
+ (input.phase === "review" ? " phase=review consumer=review" : ""));
3072
+ }
3073
+ /**
3074
+ * BAPI-1365: classify a successfully fetched review-consumer `/model-tier`
3075
+ * response. `consumer-unrecognized` = the server did not echo `consumer:
3076
+ * "review"` (its tier and marker are not a review decision and are never
3077
+ * interpreted); `legacy` = it echoed review but no stored review thresholds
3078
+ * decided the tier. `undefined` = a usable review decision, OR a
3079
+ * `source: "fallback"` response (the difficulty itself failed; callers keep the
3080
+ * existing lookup-failure handling for that).
3081
+ */
3082
+ function reviewTierFallbackCause(value) {
3083
+ if (value.consumer !== "review")
3084
+ return "consumer-unrecognized";
3085
+ if (value.source === "fallback")
3086
+ return undefined;
3087
+ if (!isThresholdDecidedTierResponse(value))
3088
+ return "legacy";
3089
+ return undefined;
3090
+ }
3091
+ /** BAPI-1365: the secret-free operator warning and diagnostic for a review fallback cause. */
3092
+ function reviewTierFallbackNotice(cause, repoName, ticketKey) {
3093
+ const kind = cause === "consumer-unrecognized" ? "review-consumer-unrecognized" : "review-threshold-legacy";
3094
+ const warning = cause === "consumer-unrecognized"
3095
+ ? `model routing: server did not recognize the review consumer for ${ticketKey} (reason=consumer-unrecognized)`
3096
+ : `model routing: review thresholds are not configured for ${ticketKey} (reason=legacy)`;
3097
+ return {
3098
+ warning,
3099
+ diagnostic: makeModelRoutingDiagnostic({
3100
+ kind,
3101
+ repoName,
3102
+ storeTarget: `bapi:${repoName}`,
3103
+ ticketKey,
3104
+ message: warning,
3105
+ }),
3106
+ };
3107
+ }
3108
+ /**
3109
+ * BAPI-1343 R-6: the threshold marker for a successfully fetched `/model-tier`
3110
+ * response when — and only when — the maximize downshift must be skipped:
3111
+ * posture is `maximize_subscription` AND the response is threshold-decided
3112
+ * (`configured` or `anomaly`). Every other case returns `undefined`, so the
3113
+ * caller keeps today's exact downshift and text.
3114
+ */
3115
+ function thresholdSkipMarker(posture, tierResult, reviewWorkflow = false) {
3116
+ if (posture !== "maximize_subscription")
3117
+ return undefined;
3118
+ if (!tierResult || !tierResult.ok)
3119
+ return undefined;
3120
+ // BAPI-1365: a review-consumer lookup counts only when the server echoed the
3121
+ // review consumer; an older server that ignored the parameter answered for
3122
+ // the implementation consumer, which says nothing about the review threshold.
3123
+ if (reviewWorkflow && tierResult.value.consumer !== "review")
3124
+ return undefined;
3125
+ if (!isThresholdDecidedTierResponse(tierResult.value))
3126
+ return undefined;
3127
+ return tierResult.value.thresholdMode;
2977
3128
  }
2978
3129
  /**
2979
3130
  * Best-effort emitter: calls the supplied stderr sink once and swallows any
@@ -3140,6 +3291,12 @@ errorLog = (message) => console.error(message)) {
3140
3291
  // produced it.
3141
3292
  const postureResolution = await resolveOptimizationPostureForStartTickets(access);
3142
3293
  const posture = postureResolution.posture;
3294
+ // BAPI-1365: the spawned tab of a `review-and-implement` launch is a REVIEW
3295
+ // tab, so its tier lookups ask for the review consumer. An ordinary
3296
+ // `implement` launch supplies no options and keeps the endpoint's default
3297
+ // implementation consumer. Derived once and shared by both fetch paths below.
3298
+ const isReviewWorkflow = options.workflow === "review-and-implement";
3299
+ const tierFetchOptions = isReviewWorkflow ? { consumer: "review" } : {};
3143
3300
  // BAPI-1225: one deterministic, best-effort diagnostic line per repository-level
3144
3301
  // resolution, emitted exactly here — outside the per-ticket row loop below, so a
3145
3302
  // multi-ticket invocation still logs it once for this canonical repository. Uses
@@ -3160,36 +3317,70 @@ errorLog = (message) => console.error(message)) {
3160
3317
  // chained automation handoff (e.g. `/review-and-implement`'s `--auto-tier`).
3161
3318
  // Routed through the CURRENT session's posture EXACTLY ONCE — unlike the
3162
3319
  // 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;
3320
+ // under `maximize_subscription` — while never re-deriving the tier from a
3321
+ // per-ticket lookup. The alias is resolved+validated at most ONCE per
3322
+ // distinct effective tier, matching the premium fallback / injected-tier
3323
+ // hoisting pattern above.
3324
+ //
3325
+ // BAPI-1343 R-6: under `maximize_subscription` ONLY, each eligible row's
3326
+ // `/model-tier` is fetched once (the shared bounded fetch; BAPI-1365: with
3327
+ // the workflow's consumer) purely to read `threshold_mode`. A threshold-decided
3328
+ // (`configured`/`anomaly`) response keeps the carried tier — stored
3329
+ // thresholds made it final. A fetch failure, `legacy`, or anything else
3330
+ // downshifts exactly as before. `quality`/`speed` never downshift, so they
3331
+ // make no extra request.
3332
+ const thresholdTierMap = posture === "maximize_subscription"
3333
+ ? await fetchTicketModelTiersForRows(access, eligible, options.maxParallel, isEligible, tierFetchOptions)
3334
+ : null;
3335
+ const aliasByEffectiveTier = new Map();
3336
+ const resolveAutoTierAlias = async (effectiveTier) => {
3337
+ const cached = aliasByEffectiveTier.get(effectiveTier);
3338
+ if (cached)
3339
+ return cached;
3340
+ const alias = resolveRoutingAlias(agent, effectiveTier, overrides, catalog);
3341
+ let resolved;
3342
+ if (!alias) {
3343
+ resolved = {
3344
+ resolvedAlias: null,
3345
+ aliasWarning: `model routing: no valid alias for tier=${effectiveTier}; using agent default`,
3346
+ };
3182
3347
  }
3183
3348
  else {
3184
- aliasWarning = validation.warning;
3349
+ const validation = await validateResolvedModelAliasForAgent(deps, agent, alias);
3350
+ resolved = validation.ok
3351
+ ? { resolvedAlias: validation.alias, aliasWarning: null }
3352
+ : { resolvedAlias: null, aliasWarning: validation.warning };
3185
3353
  }
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;
3354
+ aliasByEffectiveTier.set(effectiveTier, resolved);
3355
+ return resolved;
3356
+ };
3357
+ const autoOut = [];
3358
+ for (const row of rows) {
3359
+ if (!isEligible(row)) {
3360
+ autoOut.push(row);
3361
+ continue;
3362
+ }
3363
+ const markerResult = thresholdTierMap?.get(row.key);
3364
+ const threshold = thresholdSkipMarker(posture, markerResult, isReviewWorkflow);
3365
+ // BAPI-1365: for a review launch, say WHY a fetched marker did not decide
3366
+ // the tier (never a review-threshold claim). A failed fetch keeps the
3367
+ // existing silent downshift.
3368
+ const markerCause = isReviewWorkflow && markerResult && markerResult.ok
3369
+ ? reviewTierFallbackCause(markerResult.value)
3370
+ : undefined;
3371
+ const effectiveTier = posture === "maximize_subscription" && !threshold
3372
+ ? downshiftModelTier(autoTier)
3373
+ : autoTier;
3374
+ const adjustment = resolveModelRoutingDecisionAdjustment(posture, autoTier, threshold);
3375
+ const lineageSuffix = threshold
3376
+ ? `; threshold:${threshold}`
3377
+ : posture === "maximize_subscription"
3378
+ ? `; auto-tier:${autoTier}->maximize:${effectiveTier}${markerCause ? `; review-marker:${markerCause}` : ""}`
3379
+ : "";
3380
+ const { resolvedAlias, aliasWarning } = await resolveAutoTierAlias(effectiveTier);
3381
+ const reason = resolvedAlias
3382
+ ? `auto-tier=${autoTier} model=${resolvedAlias} (posture=${posture})${lineageSuffix}`
3383
+ : `${aliasWarning}${lineageSuffix}`;
3193
3384
  const baseRow = {
3194
3385
  ...row,
3195
3386
  difficulty: null,
@@ -3201,14 +3392,17 @@ errorLog = (message) => console.error(message)) {
3201
3392
  ? { modelRoutingDiagnostic: codexEntitlementDiagnostic(access.repoName) }
3202
3393
  : {}),
3203
3394
  };
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;
3395
+ let pushedRow = aliasWarning ? appendSummaryRowWarning(baseRow, aliasWarning) : baseRow;
3396
+ if (markerCause) {
3397
+ const notice = reviewTierFallbackNotice(markerCause, access.repoName, row.key);
3398
+ pushedRow = appendSummaryRowWarning(pushedRow, notice.warning);
3399
+ if (!pushedRow.modelRoutingDiagnostic)
3400
+ pushedRow = { ...pushedRow, modelRoutingDiagnostic: notice.diagnostic };
3401
+ }
3402
+ autoOut.push(pushedRow);
3403
+ // BAPI-1236: one `automatic_snapshot` diagnostic per eligible row — the
3404
+ // carried baseline, the newly resolved posture, the exact adjustment token,
3405
+ // the calculated effective tier, and the validated alias (or `unresolved`).
3212
3406
  emitModelRoutingDecisionDiagnostic(errorLog, {
3213
3407
  source: "automatic_snapshot",
3214
3408
  baseline: autoTier,
@@ -3216,12 +3410,15 @@ errorLog = (message) => console.error(message)) {
3216
3410
  adjustment,
3217
3411
  effective: effectiveTier,
3218
3412
  alias: resolvedAlias,
3413
+ ...(threshold ? { threshold } : {}),
3414
+ ...(isReviewWorkflow ? { phase: "review" } : {}),
3219
3415
  });
3220
3416
  }
3417
+ out = autoOut;
3221
3418
  }
3222
3419
  else {
3223
3420
  // Ordinary per-ticket difficulty-lookup path.
3224
- const tierMap = await fetchTicketModelTiersForRows(access, eligible, options.maxParallel, isEligible);
3421
+ const tierMap = await fetchTicketModelTiersForRows(access, eligible, options.maxParallel, isEligible, tierFetchOptions);
3225
3422
  // Lazily resolve+validate the premium fallback alias at most once for this run
3226
3423
  // (the alias is identical across rows; cursor-agent validation spawns a probe).
3227
3424
  let premiumApplier = null;
@@ -3260,6 +3457,40 @@ errorLog = (message) => console.error(message)) {
3260
3457
  continue;
3261
3458
  }
3262
3459
  const { difficulty, tier, source } = tierResult.value;
3460
+ // BAPI-1365: a review-launch response is a review-threshold decision only
3461
+ // when the server echoed `consumer: "review"` AND stored thresholds decided
3462
+ // it. Anything else takes the existing premium fallback with a closed
3463
+ // reason — the returned tier and marker are never interpreted.
3464
+ if (isReviewWorkflow) {
3465
+ const cause = reviewTierFallbackCause(tierResult.value);
3466
+ if (cause) {
3467
+ const notice = reviewTierFallbackNotice(cause, access.repoName, row.key);
3468
+ if (agent.name === "codex") {
3469
+ loopOut.push(codexDefaultRows([row], () => true, notice.warning, notice.diagnostic)[0]);
3470
+ continue;
3471
+ }
3472
+ loopOut.push(await applyPremiumFallback(row, notice.warning, notice.diagnostic));
3473
+ continue;
3474
+ }
3475
+ if (source === "fallback") {
3476
+ // The server could not obtain a difficulty: its premium tier is a
3477
+ // guess, not a review decision — keep the lookup-failure handling.
3478
+ const warning = `model routing: tier lookup failed for ${row.key}; using agent default model`;
3479
+ const diagnostic = makeModelRoutingDiagnostic({
3480
+ kind: "tier-fetch-unavailable",
3481
+ repoName: access.repoName,
3482
+ storeTarget: `bapi:${access.repoName}`,
3483
+ ticketKey: row.key,
3484
+ message: `tier lookup failed for ${row.key}`,
3485
+ });
3486
+ if (agent.name === "codex") {
3487
+ loopOut.push(codexDefaultRows([row], () => true, warning, diagnostic)[0]);
3488
+ continue;
3489
+ }
3490
+ loopOut.push(await applyPremiumFallback(row, warning, diagnostic));
3491
+ continue;
3492
+ }
3493
+ }
3263
3494
  if (!tier) {
3264
3495
  // Backend reported no usable difficulty — default to premium/Opus. (The
3265
3496
  // backend now returns a premium fallback itself, so this is defensive.)
@@ -3298,12 +3529,27 @@ errorLog = (message) => console.error(message)) {
3298
3529
  // alias table. Copilot never reaches this loop at all: it has no
3299
3530
  // `AGENT_REGISTRY` entry and `isRunnableHostTarget()` rejects hosts
3300
3531
  // without a launch agent before an `AgentSpec` is ever resolved.
3532
+ //
3533
+ // BAPI-1343 R-6: the response is already in hand — when it is
3534
+ // threshold-decided (`configured`/`anomaly`), stored thresholds made the
3535
+ // tier final, so the maximize downshift is skipped and the lineage and
3536
+ // diagnostic carry a distinct `threshold` marker instead. The harness-
3537
+ // local premium fallback above is untouched.
3301
3538
  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
- : "";
3539
+ const threshold = thresholdSkipMarker(posture, tierResult, isReviewWorkflow);
3540
+ const effectiveTier = posture === "maximize_subscription" && !threshold
3541
+ ? downshiftModelTier(difficultyTier)
3542
+ : difficultyTier;
3543
+ const adjustment = resolveModelRoutingDecisionAdjustment(posture, difficultyTier, threshold);
3544
+ const lineageSuffix = threshold
3545
+ ? `; threshold:${threshold}`
3546
+ : posture === "maximize_subscription"
3547
+ ? `; difficulty:${difficultyTier}->maximize:${effectiveTier}`
3548
+ : "";
3549
+ const thresholdField = {
3550
+ ...(threshold ? { threshold } : {}),
3551
+ ...(isReviewWorkflow ? { phase: "review" } : {}),
3552
+ };
3307
3553
  // BAPI-1236: stamp `difficulty_lookup` rather than copying the backend
3308
3554
  // cache/computation source (`cached`/`computed`/`fallback`) into the
3309
3555
  // summary's own routing-provenance field — that backend source is
@@ -3325,6 +3571,7 @@ errorLog = (message) => console.error(message)) {
3325
3571
  adjustment,
3326
3572
  effective: effectiveTier,
3327
3573
  alias: null,
3574
+ ...thresholdField,
3328
3575
  });
3329
3576
  continue;
3330
3577
  }
@@ -3338,6 +3585,7 @@ errorLog = (message) => console.error(message)) {
3338
3585
  adjustment,
3339
3586
  effective: effectiveTier,
3340
3587
  alias: null,
3588
+ ...thresholdField,
3341
3589
  });
3342
3590
  continue;
3343
3591
  }
@@ -3356,6 +3604,7 @@ errorLog = (message) => console.error(message)) {
3356
3604
  adjustment,
3357
3605
  effective: effectiveTier,
3358
3606
  alias: validation.alias,
3607
+ ...thresholdField,
3359
3608
  });
3360
3609
  }
3361
3610
  out = loopOut;
@@ -3652,7 +3901,7 @@ export async function orchestrateStartTickets(deps, options, overrides = {}) {
3652
3901
  // epic.base_branch override applied above) so the spawned worker gets
3653
3902
  // BAPI_BASE_BRANCH — every conductor worker, and a plain worker whose base
3654
3903
  // is non-default.
3655
- options.baseBranch, bridgeApiBaseUrl);
3904
+ options.baseBranch, bridgeApiBaseUrl, isModelTier(options.injectedTier) ? options.injectedTier : undefined);
3656
3905
  if (!platformConfig.ok)
3657
3906
  return { ok: false, error: platformConfig.error };
3658
3907
  // BAPI-527: resolve the base start point new worktrees are cut from BEFORE any
@@ -3681,6 +3930,11 @@ export async function orchestrateStartTickets(deps, options, overrides = {}) {
3681
3930
  });
3682
3931
  if (!refresh.ok)
3683
3932
  return { ok: false, error: refresh.error };
3933
+ // BAPI-1362: a live plane member runs from the base worktree, so the local
3934
+ // branch was left where it was; cut from the fetched remote SHA instead.
3935
+ if (refresh.protectedBaseSha !== undefined) {
3936
+ effectiveBaseStartPoint = refresh.protectedBaseSha;
3937
+ }
3684
3938
  }
3685
3939
  // BAPI-941: the `claude-review` workflow-drift advisory, emitted ONCE per
3686
3940
  // worker spawn at the one choke point both dispatch paths pass through, after
@@ -3835,14 +4089,13 @@ export async function orchestrateStartTickets(deps, options, overrides = {}) {
3835
4089
  const rows = await spawnTabsFn(deps, scoped, terminal, platformConfig.config.buildAgentShellCommand);
3836
4090
  return { ok: true, rows };
3837
4091
  }
3838
- /** Platform-specific guidance printed when one or more tabs fail to spawn. */
3839
- function spawnFailureHintForPlatform(platform) {
4092
+ /**
4093
+ * Platform-specific guidance printed when one or more tabs fail to spawn.
4094
+ * Exported for the BAPI-1319 byte-identity pin on the darwin sentence.
4095
+ */
4096
+ export function spawnFailureHintForPlatform(platform) {
3840
4097
  if (platform === "darwin") {
3841
- return ("One or more tabs failed to spawn. If osascript reported a permission error, grant the " +
3842
- "required macOS permissions under System Settings -> Privacy & Security, then re-run: " +
3843
- "Automation (to let the terminal app be controlled) and, for the Terminal.app path, " +
3844
- "Accessibility (the new-tab step sends Cmd-T via System Events keystrokes, which Automation " +
3845
- "alone does not cover).");
4098
+ return "One or more tabs failed to spawn. " + macosTerminalPermissionGuidance();
3846
4099
  }
3847
4100
  if (platform === "win32") {
3848
4101
  return ("One or more tabs failed to spawn. Ensure Windows Terminal (wt.exe) or PowerShell is " +
@@ -3918,7 +4171,7 @@ export async function runStartTicketsCli(argv, overrides = {}) {
3918
4171
  const branch = resolveBranchForTicket(key, options.branchOverrides);
3919
4172
  const routedRow = routedByKey.get(key);
3920
4173
  const modelAlias = routedRow?.modelAlias ?? null;
3921
- for (const line of buildDryRunDetailLines(agent, key, branch, deps.platform, deps.env, options.baseBranch, options.autoApprove, modelAlias, options.conductorEnabled ?? false, dryRunRepoName, dryRunMcpInvocation, options.workflow, options.reviewRounds)) {
4174
+ for (const line of buildDryRunDetailLines(agent, key, branch, deps.platform, deps.env, options.baseBranch, options.autoApprove, modelAlias, options.conductorEnabled ?? false, dryRunRepoName, dryRunMcpInvocation, options.workflow, options.reviewRounds, isModelTier(options.injectedTier) ? options.injectedTier : undefined)) {
3922
4175
  log(line);
3923
4176
  }
3924
4177
  log(`DRY-RUN: model routing: ${formatModelRoutingLine(routedRow ?? { key, branch, status: "dry-run" }, agent, { workflow: options.workflow })}`);