@bridge_gpt/mcp-server 0.2.65 → 0.2.66

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 (71) hide show
  1. package/README.md +25 -13
  2. package/build/agent-launchers/claude-executor-adapter.js +12 -18
  3. package/build/agent-launchers/executor-adapter.js +27 -0
  4. package/build/bridge-config.js +104 -0
  5. package/build/codex-bridge-profile.js +10 -1
  6. package/build/commands.generated.js +4 -4
  7. package/build/conductor/bridge-api-client.js +230 -10
  8. package/build/conductor/epic-child-continuation.js +61 -34
  9. package/build/conductor/epic-implementer-cli.js +12 -0
  10. package/build/conductor/guided-repair.js +323 -0
  11. package/build/conductor/readiness-cli.js +30 -9
  12. package/build/conductor/readiness.js +23 -1
  13. package/build/conductor/recovery-cli.js +507 -16
  14. package/build/conductor/recovery-operations.js +325 -2
  15. package/build/conductor/repair-contract.js +61 -3
  16. package/build/credential-store.js +5 -4
  17. package/build/diagnostic-text.js +36 -0
  18. package/build/docs.generated.js +1 -1
  19. package/build/doctor.js +70 -10
  20. package/build/drive-epic.js +395 -78
  21. package/build/epic-implementer-bin.js +24 -22
  22. package/build/epic-launch-diagnostic.js +31 -0
  23. package/build/epic-launch-profile.js +44 -14
  24. package/build/executor/completion-supervision.js +5 -0
  25. package/build/executor/correction-dispatch.js +60 -0
  26. package/build/executor/env.js +6 -0
  27. package/build/executor/http-client.js +15 -0
  28. package/build/executor/integration-job.js +6 -1
  29. package/build/executor/job-errors.js +13 -4
  30. package/build/executor/job-log-registry.js +340 -73
  31. package/build/executor/job-runner.js +260 -33
  32. package/build/executor/job-types.js +8 -13
  33. package/build/executor/preflight.js +91 -2
  34. package/build/executor/prompt-spec.js +113 -9
  35. package/build/executor/runner.js +23 -0
  36. package/build/executor/stale-artifacts.js +5 -4
  37. package/build/executor/types.js +1 -0
  38. package/build/executor/watch-cli.js +35 -8
  39. package/build/executor/worker-command.js +43 -43
  40. package/build/executor/worker-finalization.js +80 -15
  41. package/build/executor/worker-guard-hook.js +46 -10
  42. package/build/executor/worker-log.js +137 -70
  43. package/build/executor/worktree-gc.js +30 -6
  44. package/build/index.js +177 -329
  45. package/build/init.js +107 -4
  46. package/build/install-bridge.js +284 -20
  47. package/build/install-doctor.js +172 -30
  48. package/build/install-reexec.js +69 -7
  49. package/build/macos-terminal-permission-guidance.js +16 -0
  50. package/build/model-thresholds-tool.js +185 -0
  51. package/build/pipelines.generated.js +1 -1
  52. package/build/plane/status.js +4 -0
  53. package/build/plane/supervisor.js +4 -0
  54. package/build/plane/worker-exit-code.js +2 -0
  55. package/build/project-root.js +99 -0
  56. package/build/readme.generated.js +1 -1
  57. package/build/review-tickets.js +156 -13
  58. package/build/setup-epic.js +180 -108
  59. package/build/sfcc/setup-status.js +2 -0
  60. package/build/sfcc/tool-wrapper.js +2 -0
  61. package/build/sfcc/tools-configured.js +96 -0
  62. package/build/start-tickets.js +163 -58
  63. package/build/upgrade-cli.js +109 -24
  64. package/build/version.generated.js +3 -3
  65. package/build/windows-npm-entry.js +103 -0
  66. package/build/worker-guard-hook-bin.js +4 -3
  67. package/build/worktree-core.js +6 -5
  68. package/docs/install/mcp-tool-integrations.md +1 -0
  69. package/docs/install/sfcc-integration.md +12 -7
  70. package/package.json +1 -1
  71. package/pipelines/implement-ticket.json +1 -1
@@ -3,12 +3,22 @@
3
3
  *
4
4
  * Reuses the `start-tickets` terminal spawn layer directly, running reviews in
5
5
  * the current `deps.cwd`. Intentionally does NOT create Worktrunk worktrees,
6
- * provision MCP registrations, run venv hooks, or perform difficulty/model-tier
7
- * routing. One terminal tab per ticket is spawned with `/review-ticket <KEY>
8
- * [--auto] [--rounds=<1|2>]` as the agent prompt. `--rounds` is OMITTED by
9
- * default so the backend's difficulty-adaptive review policy chooses the shape;
10
- * it is only forwarded when the caller passes an explicit `--rounds`/`--review`
11
- * round override.
6
+ * provision MCP registrations, or run venv hooks. One terminal tab per ticket
7
+ * is spawned with `/review-ticket <KEY> [--auto] [--rounds=<1|2>]` as the agent
8
+ * prompt. `--rounds` is OMITTED by default so the backend's difficulty-adaptive
9
+ * review policy chooses the shape; it is only forwarded when the caller passes
10
+ * an explicit `--rounds`/`--review` round override.
11
+ *
12
+ * BAPI-1343 — review-threshold model routing for spawned tabs: an explicit
13
+ * `--model` always wins and no tier lookup is made. Otherwise each ticket's
14
+ * `/tickets/{n}/model-tier?consumer=review` is fetched (the shared bounded
15
+ * `start-tickets` fetcher, at most 3 at once), and a tab gets `--model` only
16
+ * when the server echoes `consumer: "review"` and reports a threshold-decided
17
+ * (`configured` or `anomaly`) tier, mapped to an alias exactly as
18
+ * `start-tickets` maps it. Legacy mode, a failed or timed-out lookup, or an
19
+ * older server that does not echo the consumer passes no `--model` (today's
20
+ * behavior); there is no premium fallback. `--dry-run` makes no lookup, and the
21
+ * epic-dispatch path below is not re-routed.
12
22
  *
13
23
  * Optionally supports a supervisor-driven epic-dispatch path: when an
14
24
  * `EpicDispatchIdentity` is threaded through `ReviewTicketsOptions.epic`, each
@@ -18,7 +28,7 @@
18
28
  * This path is worktree-free and base-branch-free; the human CLI path leaves
19
29
  * `epic` unset and retains byte-for-byte identical behavior.
20
30
  */
21
- import { createDefaultStartTicketsDeps, detectTerminal, runWithConcurrency, buildAgentInvocation, shSquoteInner, powershellSquote, isCommandOnPath, resolveFirstCommandOnPath, tmuxSessionNameForTicket, TICKET_KEY_PATTERN, DEFAULT_MAX_PARALLEL, validateBranchName, fetchAndResolveBaseSha, fetchStartTicketsConfigField, resolveStartTicketsBridgeApiAccess, buildCommandProvisioningDeps, buildMcpProvisioningDeps, } from "./start-tickets.js";
31
+ import { createDefaultStartTicketsDeps, detectTerminal, runWithConcurrency, buildAgentInvocation, shSquoteInner, powershellSquote, isCommandOnPath, resolveFirstCommandOnPath, tmuxSessionNameForTicket, TICKET_KEY_PATTERN, DEFAULT_MAX_PARALLEL, validateBranchName, fetchAndResolveBaseSha, fetchStartTicketsConfigField, resolveStartTicketsBridgeApiAccess, buildCommandProvisioningDeps, buildMcpProvisioningDeps, fetchTicketModelTiersForKeys, isThresholdDecidedTierResponse, normalizeDifficultyModelTierOverrides, fetchCodexModelCatalog, resolveRoutingAlias, validateResolvedModelAliasForAgent, } from "./start-tickets.js";
22
32
  import { provisionCommandsForWorktree } from "./command-provisioning.js";
23
33
  import { resolveCodexLaunchScope } from "./codex-launch-scope.js";
24
34
  import { WINDOWS_TERMINAL_COMMAND, WINDOWS_POWERSHELL_CANDIDATES, TMUX_COMMAND, isSupportedStartTicketsPlatform, unsupportedPlatformMessage, } from "./start-tickets-prereqs.js";
@@ -48,7 +58,7 @@ export function getReviewTicketsUsage() {
48
58
  " --rounds=1|2 Review depth: 1=single-pass, 2=full two-pass (default: adaptive — backend routes by difficulty)",
49
59
  " --review KEY=auto,rounds=1 Per-ticket review mode override (repeatable)",
50
60
  " --agent claude|cursor-agent|codex Agent command to launch in each tab (default: claude)",
51
- " --model VALUE Model alias to pass as --model to the agent (optional passthrough)",
61
+ " --model VALUE Model alias for every tab (always wins). Unset: each tab is routed by the review threshold; legacy mode or an older server passes no --model",
52
62
  " --max-parallel N Max tabs to spawn concurrently (default: 3)",
53
63
  " --dry-run Print intended commands; creates no tabs and opens no sessions",
54
64
  " --base-branch BRANCH Base branch to fetch+pin for grounding (default: config base_branch, else \"main\")",
@@ -527,12 +537,125 @@ export function buildReviewPlanRows(deps, options, agent, status, overrides = {}
527
537
  rounds: mode.rounds,
528
538
  agentName: options.agentName,
529
539
  modelAlias: options.modelAlias,
540
+ // BAPI-1343: plan rows (the dry-run preview) never perform a tier lookup;
541
+ // they show only an explicit --model, or the agent default.
542
+ ...(!options.epic && options.modelAlias ? { modelRoutingReason: "explicit" } : {}),
530
543
  status,
531
544
  command,
532
545
  runId,
533
546
  };
534
547
  });
535
548
  }
549
+ /**
550
+ * BAPI-1343 — resolve each spawned tab's model from the review threshold.
551
+ *
552
+ * One `/model-tier?consumer=review` request per key through the shared
553
+ * `start-tickets` fetcher (bounded at 3, never throws). A tab is routed only
554
+ * when the server echoes `consumer: "review"` AND reports a threshold-decided
555
+ * tier; the tier maps to an alias exactly as `start-tickets` maps it (registry
556
+ * plus `difficulty_model_tier_overrides`, read once per batch; the Codex
557
+ * catalog, fetched once per batch; `validateResolvedModelAliasForAgent`, run
558
+ * at most once per distinct alias per batch). Every other outcome returns
559
+ * `modelAlias: null` (no `--model`, today's behavior) — there is no premium
560
+ * fallback. Callers handle explicit `--model`, `--dry-run`, and epic dispatch
561
+ * before calling this, so it always performs lookups.
562
+ */
563
+ export async function resolveReviewModelRoutingForKeys(deps, keys, agent, maxParallel) {
564
+ const decisions = new Map();
565
+ const noModel = (reason) => ({ modelAlias: null, reason });
566
+ let accessResult;
567
+ try {
568
+ accessResult = await resolveStartTicketsBridgeApiAccess(deps);
569
+ }
570
+ catch {
571
+ for (const key of keys)
572
+ decisions.set(key, noModel("lookup-failed"));
573
+ return decisions;
574
+ }
575
+ if (!accessResult.ok) {
576
+ for (const key of keys)
577
+ decisions.set(key, noModel("lookup-failed"));
578
+ return decisions;
579
+ }
580
+ const access = accessResult.access;
581
+ const tierMap = await fetchTicketModelTiersForKeys(access, keys, maxParallel, { consumer: "review" });
582
+ // Batch-scoped alias inputs, loaded lazily so a batch with no
583
+ // threshold-decided ticket makes no config/catalog request.
584
+ let aliasInputs = null;
585
+ const loadAliasInputs = () => {
586
+ if (!aliasInputs) {
587
+ aliasInputs = (async () => {
588
+ try {
589
+ if (agent.name === "codex") {
590
+ const catalogResult = await fetchCodexModelCatalog(access);
591
+ return catalogResult.ok ? { overrides: null, catalog: catalogResult.catalog } : null;
592
+ }
593
+ const raw = await fetchStartTicketsConfigField(access, "difficulty_model_tier_overrides");
594
+ return { overrides: normalizeDifficultyModelTierOverrides(raw), catalog: null };
595
+ }
596
+ catch {
597
+ return null;
598
+ }
599
+ })();
600
+ }
601
+ return aliasInputs;
602
+ };
603
+ const validations = new Map();
604
+ const validateOnce = (alias) => {
605
+ let pending = validations.get(alias);
606
+ if (!pending) {
607
+ pending = validateResolvedModelAliasForAgent(deps, agent, alias).catch(() => ({ ok: false, warning: "model validation failed" }));
608
+ validations.set(alias, pending);
609
+ }
610
+ return pending;
611
+ };
612
+ for (const key of keys) {
613
+ const result = tierMap.get(key);
614
+ if (!result || !result.ok) {
615
+ decisions.set(key, noModel("lookup-failed"));
616
+ continue;
617
+ }
618
+ const value = result.value;
619
+ if (value.consumer !== "review") {
620
+ decisions.set(key, noModel("consumer-unrecognized"));
621
+ continue;
622
+ }
623
+ if (value.source === "fallback") {
624
+ // The server could not obtain a difficulty; its premium tier is a guess,
625
+ // and review routing has no premium fallback.
626
+ decisions.set(key, noModel("lookup-failed"));
627
+ continue;
628
+ }
629
+ if (!isThresholdDecidedTierResponse(value)) {
630
+ decisions.set(key, noModel("legacy"));
631
+ continue;
632
+ }
633
+ if (!value.tier) {
634
+ decisions.set(key, noModel("lookup-failed"));
635
+ continue;
636
+ }
637
+ if (!agent.supportsModelOverride) {
638
+ decisions.set(key, noModel("alias-unresolved"));
639
+ continue;
640
+ }
641
+ const inputs = await loadAliasInputs();
642
+ const candidate = inputs ? resolveRoutingAlias(agent, value.tier, inputs.overrides, inputs.catalog) : null;
643
+ if (!candidate) {
644
+ decisions.set(key, noModel("alias-unresolved"));
645
+ continue;
646
+ }
647
+ const validation = await validateOnce(candidate);
648
+ if (!validation.ok) {
649
+ decisions.set(key, noModel("alias-unresolved"));
650
+ continue;
651
+ }
652
+ decisions.set(key, {
653
+ modelAlias: validation.alias,
654
+ reason: value.thresholdMode === "anomaly" ? "review-threshold:anomaly" : "review-threshold:configured",
655
+ });
656
+ }
657
+ return decisions;
658
+ }
536
659
  /** Build a dry-run preview base context from parsed CLI options only — never fetches. */
537
660
  function previewBaseContextFromOptions(options) {
538
661
  if (options.noRefreshBase)
@@ -590,10 +713,25 @@ export async function orchestrateReviewTickets(deps, options, overrides = {}) {
590
713
  return { ok: false, error: baseContextResult.error };
591
714
  }
592
715
  const baseContext = baseContextResult.context;
716
+ // BAPI-1343: per-tab review-threshold routing for the human CLI path only.
717
+ // An explicit --model wins with no lookup; the epic-dispatch path keeps its
718
+ // pre-feature shape (Epic workers take their tier from the stamped payload).
719
+ let routing = null;
720
+ if (!options.epic) {
721
+ if (options.modelAlias) {
722
+ routing = new Map(options.keys.map((key) => [key, { modelAlias: options.modelAlias ?? null, reason: "explicit" }]));
723
+ }
724
+ else {
725
+ routing = await resolveReviewModelRoutingForKeys(deps, options.keys, agent, options.maxParallel);
726
+ }
727
+ }
593
728
  const terminal = detectTerminal(undefined, deps.env);
594
729
  const rows = await runWithConcurrency(options.keys, options.maxParallel, async (key) => {
595
730
  const mode = resolveEffectiveReviewMode(key, options);
596
- const baseShellCommand = buildReviewAgentShellCommand(agent, key, mode, deps.platform, deps.cwd, options.modelAlias, baseContext, codexLaunchArgs);
731
+ const decision = routing?.get(key);
732
+ const modelAlias = decision ? decision.modelAlias : options.modelAlias;
733
+ const reasonField = decision ? { modelRoutingReason: decision.reason } : {};
734
+ const baseShellCommand = buildReviewAgentShellCommand(agent, key, mode, deps.platform, deps.cwd, modelAlias, baseContext, codexLaunchArgs);
597
735
  let shellCommand = baseShellCommand;
598
736
  let runId;
599
737
  if (options.epic) {
@@ -608,7 +746,8 @@ export async function orchestrateReviewTickets(deps, options, overrides = {}) {
608
746
  auto: mode.auto,
609
747
  rounds: mode.rounds,
610
748
  agentName: options.agentName,
611
- modelAlias: options.modelAlias,
749
+ modelAlias,
750
+ ...reasonField,
612
751
  status: "spawn-failed",
613
752
  command: shellCommand,
614
753
  runId,
@@ -625,7 +764,8 @@ export async function orchestrateReviewTickets(deps, options, overrides = {}) {
625
764
  auto: mode.auto,
626
765
  rounds: mode.rounds,
627
766
  agentName: options.agentName,
628
- modelAlias: options.modelAlias,
767
+ modelAlias,
768
+ ...reasonField,
629
769
  status: "already-dispatched",
630
770
  command: shellCommand,
631
771
  runId,
@@ -645,7 +785,8 @@ export async function orchestrateReviewTickets(deps, options, overrides = {}) {
645
785
  auto: mode.auto,
646
786
  rounds: mode.rounds,
647
787
  agentName: options.agentName,
648
- modelAlias: options.modelAlias,
788
+ modelAlias,
789
+ ...reasonField,
649
790
  status: "spawned",
650
791
  command: shellCommand,
651
792
  runId,
@@ -687,9 +828,11 @@ export function formatReviewTicketsSummaryReport(rows) {
687
828
  const lines = ["Summary:"];
688
829
  for (const row of rows) {
689
830
  const modelPart = row.modelAlias ? row.modelAlias : "default";
831
+ // BAPI-1343: the closed, secret-free routing reason, when one was recorded.
832
+ const modelReasonPart = row.modelRoutingReason ? ` model_reason=${row.modelRoutingReason}` : "";
690
833
  const roundsPart = row.rounds !== undefined ? String(row.rounds) : "adaptive";
691
834
  const runIdPart = row.runId ? ` run_id=${row.runId}` : "";
692
- lines.push(`${row.key} auto=${row.auto} rounds=${roundsPart} agent=${row.agentName} model=${modelPart} status=${row.status}${runIdPart}`);
835
+ lines.push(`${row.key} auto=${row.auto} rounds=${roundsPart} agent=${row.agentName} model=${modelPart}${modelReasonPart} status=${row.status}${runIdPart}`);
693
836
  }
694
837
  const failedRows = rows.filter((r) => r.status === "spawn-failed" || r.status === "already-dispatched");
695
838
  if (failedRows.length > 0) {
@@ -78,7 +78,8 @@
78
78
  import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
79
79
  import os from "node:os";
80
80
  import { createProductionEpicIntegrationGhRunner, ensureEpicIntegrationPullRequest, formatEpicIntegrationPullRequestOutcome, } from "./epic-integration-pr.js";
81
- import { approveEpicPlan, buildConductorJiraUrl, conductorGetHeaders, createEpicRunWithDisposition, decodeCanonicalProvenance, fetchConductorReadiness, fetchEpicRunState, replaceEpicRunPolicy, resolveConductorBridgeApiAccess, resolveEpicLaunchBridgeApiAccess, storeEpicPlan, validateEpicPlan, ConductorBridgeApiError, } from "./conductor/bridge-api-client.js";
81
+ import { approveEpicPlan, buildConductorJiraUrl, conductorGetHeaders, createEpicRunWithDisposition, decodeCanonicalProvenance, fetchConductorReadiness, fetchEpicRunState, replaceEpicRunPolicy, resolveConductorBridgeApiAccess, resolveEpicLaunchBridgeApiAccess, safeDiagnosticMessage, storeEpicPlan, validateEpicPlan, ConductorBridgeApiError, } from "./conductor/bridge-api-client.js";
82
+ import { formatEpicLaunchChannelIdentity, getPinnedEpicLaunchPolicy, readEpicLaunchProfile, } from "./epic-launch-profile.js";
82
83
  // BAPI-1133 (A2) — the single legacy-to-typed merge-execution-mode mapping and
83
84
  // its total legacy resolver live in `merge-pull-request.ts` (no import cycle:
84
85
  // that module never imports from `setup-epic.ts`). Reused here rather than
@@ -1117,6 +1118,24 @@ function findCycle(keys, adjacency) {
1117
1118
  }
1118
1119
  return null;
1119
1120
  }
1121
+ export function effectivePolicyFacts(policy, source) {
1122
+ const object = policy && typeof policy === "object" && !Array.isArray(policy)
1123
+ ? policy : {};
1124
+ const numbers = (value) => {
1125
+ if (!value || typeof value !== "object" || Array.isArray(value))
1126
+ return null;
1127
+ const entries = Object.entries(value).filter(([key, item]) => /^[a-z][a-z0-9_]{0,63}$/.test(key) && typeof item === "number" && Number.isFinite(item));
1128
+ return entries.length ? Object.fromEntries(entries) : null;
1129
+ };
1130
+ return {
1131
+ source,
1132
+ serial_execution: "serial",
1133
+ posture: object.posture === "unattended" ? "unattended" : "attended",
1134
+ caps: numbers(object.caps),
1135
+ job_timeouts: numbers(object.job_timeouts),
1136
+ escalation_channel: formatEpicLaunchChannelIdentity(source === "stored" ? "unavailable" : policyDeclaresNotifyWebhook(object) ? "configured webhook" : "account email"),
1137
+ };
1138
+ }
1120
1139
  /** Propose a conductor epic feature branch name for an epic key (BAPI-655). */
1121
1140
  export function proposeFeatureBranchName(epicKey) {
1122
1141
  return `epic/${epicKey}`;
@@ -1561,12 +1580,7 @@ composesDefaultPolicy = true) {
1561
1580
  `(pass --feature-branch to force ${proposeFeatureBranchName(opts.epicKey)} anyway)`);
1562
1581
  }
1563
1582
  function errorDetail(err) {
1564
- if (err instanceof ConductorBridgeApiError) {
1565
- const status = err.status !== undefined ? ` (HTTP ${err.status})` : "";
1566
- const preview = err.bodyPreview ? `: ${err.bodyPreview}` : "";
1567
- return `${err.message}${status}${preview}`;
1568
- }
1569
- return err instanceof Error ? err.message : String(err);
1583
+ return safeDiagnosticMessage(err, "operation failed");
1570
1584
  }
1571
1585
  /**
1572
1586
  * Reconcile a policy file against a REUSED run's stored policy, applying it only
@@ -1588,11 +1602,13 @@ async function applyReusedRunPolicy(args) {
1588
1602
  });
1589
1603
  if (outcome.kind === "noop") {
1590
1604
  args.result.policy_applied = "noop";
1605
+ args.result.effective_policy = effectivePolicyFacts(args.storedPolicy, "stored");
1591
1606
  args.say("Policy: policy unchanged");
1592
1607
  return true;
1593
1608
  }
1594
1609
  if (outcome.kind === "refused_divergent") {
1595
1610
  args.result.policy_applied = "refused_divergent";
1611
+ args.result.effective_policy = effectivePolicyFacts(args.storedPolicy, "stored");
1596
1612
  args.deps.errorLog(`Epic run ${args.epicRunId} is live and its stored policy differs from the ` +
1597
1613
  `policy file. setup-epic will not silently change a running epic's policy.\n` +
1598
1614
  `${outcome.diffLines.join("\n")}\n` +
@@ -1605,10 +1621,12 @@ async function applyReusedRunPolicy(args) {
1605
1621
  }
1606
1622
  catch (err) {
1607
1623
  args.result.policy_applied = "refused_divergent";
1624
+ args.result.effective_policy = effectivePolicyFacts(args.storedPolicy, "stored");
1608
1625
  args.deps.errorLog(`Failed to replace the run policy: ${errorDetail(err)}`);
1609
1626
  return false;
1610
1627
  }
1611
1628
  args.result.policy_applied = "replaced";
1629
+ args.result.effective_policy = effectivePolicyFacts(args.requestedPolicy, "explicit");
1612
1630
  args.say("Policy: policy applied: replaced");
1613
1631
  for (const line of outcome.diffLines)
1614
1632
  args.say(line);
@@ -1860,12 +1878,13 @@ function describeScopeTransition(state, status) {
1860
1878
  * {@link runSetupEpicCli} in every observable way — same output, same refusals,
1861
1879
  * same exit codes — and differs only in what it hands back.
1862
1880
  */
1863
- export async function runSetupEpicWorkflow(argv, overrides = {}) {
1881
+ export async function runSetupEpicWorkflow(argv, overrides = {}, handoff) {
1864
1882
  const sink = {};
1865
- const exitCode = await executeSetupEpic(argv, overrides, sink);
1883
+ const exitCode = await executeSetupEpic(argv, overrides, sink, handoff);
1866
1884
  return {
1867
1885
  exitCode,
1868
1886
  ...(sink.epicRunId !== undefined ? { epicRunId: sink.epicRunId } : {}),
1887
+ ...(sink.epicRunId !== undefined ? { runExists: true } : {}),
1869
1888
  };
1870
1889
  }
1871
1890
  /**
@@ -1875,7 +1894,7 @@ export async function runSetupEpicWorkflow(argv, overrides = {}) {
1875
1894
  export async function runSetupEpicCli(argv, overrides = {}) {
1876
1895
  return (await runSetupEpicWorkflow(argv, overrides)).exitCode;
1877
1896
  }
1878
- async function executeSetupEpic(argv, overrides, runIdSink) {
1897
+ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
1879
1898
  const deps = { ...createDefaultSetupEpicDeps(), ...overrides };
1880
1899
  const parsed = parseSetupEpicArgs(argv);
1881
1900
  if (parsed.status === "help") {
@@ -1891,6 +1910,14 @@ async function executeSetupEpic(argv, overrides, runIdSink) {
1891
1910
  const opts = parsed.options;
1892
1911
  // With --json, stdout carries the single result object and nothing else.
1893
1912
  const say = opts.json ? deps.errorLog : deps.log;
1913
+ if (handoff && (handoff.access.baseUrl !== handoff.selection.baseUrl ||
1914
+ handoff.access.repoName !== handoff.selection.repoName ||
1915
+ (opts.baseUrl !== undefined && opts.baseUrl !== handoff.selection.baseUrl) ||
1916
+ (opts.repo !== undefined && opts.repo !== handoff.selection.repoName) ||
1917
+ (opts.launchProfile !== undefined && opts.launchProfile !== handoff.selection.profileName))) {
1918
+ deps.errorLog("setup-epic REFUSED (selection-conflict): delegated target or repository disagrees with the pinned launch selection.");
1919
+ return 1;
1920
+ }
1894
1921
  // --- Read + validate the plan sidecar (no network yet) -------------------
1895
1922
  let raw;
1896
1923
  try {
@@ -1922,18 +1949,137 @@ async function executeSetupEpic(argv, overrides, runIdSink) {
1922
1949
  return 1;
1923
1950
  }
1924
1951
  const localHash = hashPlan(plan);
1952
+ // Resolve the selected target and live-run identity before choosing a policy.
1953
+ // The offline local-only path keeps its no-network contract.
1954
+ let access;
1955
+ let selectedProfilePolicyPath = handoff?.profile?.policyRef?.path;
1956
+ if (opts.localOnly && !handoff) {
1957
+ const profileName = opts.launchProfile ?? deps.env.BAPI_EPIC_LAUNCH_PROFILE?.trim();
1958
+ if (profileName) {
1959
+ const profileRead = await readEpicLaunchProfile(profileName, {
1960
+ env: deps.env,
1961
+ homedir: deps.homedir,
1962
+ readFile: deps.readFile,
1963
+ host: os.hostname(),
1964
+ });
1965
+ if (!profileRead.ok) {
1966
+ deps.errorLog(profileRead.message);
1967
+ return 1;
1968
+ }
1969
+ selectedProfilePolicyPath = profileRead.profile.policyRef?.path;
1970
+ }
1971
+ }
1972
+ let existingRunId = null;
1973
+ let existingStatus = null;
1974
+ let existingBaseBranch = null;
1975
+ let existingReviewPolicy = null;
1976
+ // BAPI-754: the live run's SERVER-REDACTED stored policy, kept for the reuse
1977
+ // comparison. Redacted is the only form a client can ever see.
1978
+ let existingPolicyJson = null;
1979
+ if (!opts.localOnly) {
1980
+ // --- Resolve access ------------------------------------------------------
1981
+ // BAPI-1260: a caller that selected the epic-launch contract (drive-epic
1982
+ // forwards --launch-profile / --base-url) gets the target-first resolver and
1983
+ // the bound credential; the legacy resolver is kept byte-for-byte otherwise.
1984
+ const accessDeps = {
1985
+ env: deps.env,
1986
+ cwd: deps.cwd,
1987
+ homedir: deps.homedir,
1988
+ platform: deps.platform,
1989
+ readFile: deps.readFile,
1990
+ stat: deps.stat,
1991
+ repoName: opts.repo,
1992
+ };
1993
+ const accessResult = handoff
1994
+ ? { ok: true, access: handoff.access, profile: handoff.profile }
1995
+ : opts.launchProfile !== undefined || opts.baseUrl !== undefined || Boolean(deps.env.BAPI_EPIC_LAUNCH_PROFILE?.trim())
1996
+ ? await resolveEpicLaunchBridgeApiAccess({
1997
+ ...(opts.launchProfile ? { profileName: opts.launchProfile } : {}),
1998
+ ...(opts.baseUrl ? { baseUrl: opts.baseUrl } : {}),
1999
+ }, accessDeps)
2000
+ : await resolveConductorBridgeApiAccess(accessDeps);
2001
+ if (!accessResult.ok) {
2002
+ deps.errorLog(`Cannot reach the Bridge API: ${accessResult.error}`);
2003
+ return 1;
2004
+ }
2005
+ access = accessResult.access;
2006
+ say(`Epic: ${opts.epicKey}`);
2007
+ say(`Repo: ${access.repoName}`);
2008
+ say(`Plan: v${plan.plan_version}, ${plan.nodes.length} node(s), ${plan.edges.length} edge(s)`);
2009
+ say(`Local hash: ${localHash}`);
2010
+ for (const w of warnings)
2011
+ say(` [warn] ${w}`);
2012
+ selectedProfilePolicyPath = "profile" in accessResult
2013
+ ? accessResult.profile?.policyRef?.path
2014
+ : undefined;
2015
+ // --- Step 0: pre-check ---------------------------------------------------
2016
+ // Never create on an ambiguous read. A wrong answer here mints a duplicate run.
2017
+ try {
2018
+ const state = await fetchEpicRunState(access, opts.epicKey, deps.fetch);
2019
+ existingRunId = state.epic_run?.epic_run_id ?? null;
2020
+ existingStatus = state.epic_run?.status ?? null;
2021
+ const existingPolicy = state.epic_run?.policy_json;
2022
+ existingPolicyJson = existingPolicy ?? null;
2023
+ const existingBase = existingPolicy && typeof existingPolicy === "object"
2024
+ ? existingPolicy.base_branch
2025
+ : undefined;
2026
+ existingBaseBranch = typeof existingBase === "string" && existingBase.trim() !== ""
2027
+ ? existingBase
2028
+ : null;
2029
+ // BAPI-679: read the existing run's review policy for the conflict guard
2030
+ // below. Absent/malformed reads as null — "the resolver's default applies" —
2031
+ // which is deliberately NOT the same as an explicitly selected source.
2032
+ const existingReview = existingPolicy && typeof existingPolicy === "object"
2033
+ ? existingPolicy.review_policy
2034
+ : undefined;
2035
+ const existingReviewSource = existingReview && typeof existingReview === "object"
2036
+ ? existingReview.source
2037
+ : undefined;
2038
+ existingReviewPolicy =
2039
+ typeof existingReviewSource === "string" && existingReviewSource.trim() !== ""
2040
+ ? existingReviewSource
2041
+ : null;
2042
+ }
2043
+ catch (err) {
2044
+ if (err instanceof ConductorBridgeApiError && err.status === 404) {
2045
+ existingRunId = null; // No live run — the normal first-run path.
2046
+ }
2047
+ else if (err instanceof ConductorBridgeApiError && err.status === 409) {
2048
+ deps.errorLog(`Epic ${opts.epicKey} has MULTIPLE active runs — it is wedged, and every ` +
2049
+ `plan call will keep failing. Abandon the duplicate before retrying:\n` +
2050
+ ` epic-implementer abandon-run --epic-run-id <duplicate_epic_run_id>\n` +
2051
+ `Detail: ${errorDetail(err)}`);
2052
+ return 1;
2053
+ }
2054
+ else {
2055
+ deps.errorLog(`Could not read the epic run state, so creating one would risk a duplicate ` +
2056
+ `(which wedges the epic permanently). Refusing to continue.\n` +
2057
+ `Detail: ${errorDetail(err)}`);
2058
+ return 1;
2059
+ }
2060
+ }
2061
+ }
1925
2062
  // --- Read the policy file (still no network) ------------------------------
1926
2063
  // BAPI-754: parsed here so a malformed file, or a contradiction between the
1927
2064
  // file and the CLI flags, fails before credentials are resolved or any request
1928
2065
  // is dispatched.
1929
2066
  let filePolicy;
1930
- if (opts.policyFile !== undefined) {
1931
- const policyRead = await readSetupEpicPolicyFile(opts.policyFile, deps.readFile);
1932
- if (!policyRead.ok) {
1933
- deps.errorLog(policyRead.error);
1934
- return 1;
2067
+ const selectedPolicyPath = opts.policyFile ?? (existingRunId === null ? selectedProfilePolicyPath : undefined);
2068
+ const selectedPolicySource = existingRunId !== null
2069
+ ? "stored" : opts.policyFile !== undefined ? "explicit" : selectedProfilePolicyPath !== undefined ? "profile" : "default";
2070
+ if (selectedPolicyPath !== undefined) {
2071
+ const pinned = handoff ? getPinnedEpicLaunchPolicy(handoff.selection) : undefined;
2072
+ if (pinned !== undefined) {
2073
+ filePolicy = pinned;
2074
+ }
2075
+ else {
2076
+ const policyRead = await readSetupEpicPolicyFile(selectedPolicyPath, deps.readFile);
2077
+ if (!policyRead.ok) {
2078
+ deps.errorLog(policyRead.error);
2079
+ return 1;
2080
+ }
2081
+ filePolicy = policyRead.policy;
1935
2082
  }
1936
- filePolicy = policyRead.policy;
1937
2083
  }
1938
2084
  const policyDeclaresBranch = filePolicy !== undefined &&
1939
2085
  ["base_branch", "baseBranch"].some((key) => key in filePolicy && filePolicy[key] !== undefined && filePolicy[key] !== null);
@@ -1963,7 +2109,7 @@ async function executeSetupEpic(argv, overrides, runIdSink) {
1963
2109
  const policyGate = preflightSetupEpicPolicy({
1964
2110
  intoBase: opts.intoBase,
1965
2111
  attended: opts.attended,
1966
- policyFile: opts.policyFile,
2112
+ policyFile: selectedPolicyPath,
1967
2113
  filePolicy,
1968
2114
  policyDeclaresBranch,
1969
2115
  featureBranch,
@@ -2001,42 +2147,14 @@ async function executeSetupEpic(argv, overrides, runIdSink) {
2001
2147
  plan_version: plan.plan_version,
2002
2148
  local_plan_hash: localHash,
2003
2149
  server_validated: false,
2150
+ feature_branch: opts.intoBase ? null : resolveEffectiveFeatureBranch(featureBranch, policyGate.policy) ?? null,
2004
2151
  policy_applied: "absent",
2152
+ effective_policy: effectivePolicyFacts(policyGate.policy, selectedPolicySource),
2005
2153
  warnings,
2006
2154
  }, null, 2));
2007
2155
  }
2008
2156
  return 0;
2009
2157
  }
2010
- // --- Resolve access ------------------------------------------------------
2011
- // BAPI-1260: a caller that selected the epic-launch contract (drive-epic
2012
- // forwards --launch-profile / --base-url) gets the target-first resolver and
2013
- // the bound credential; the legacy resolver is kept byte-for-byte otherwise.
2014
- const accessDeps = {
2015
- env: deps.env,
2016
- cwd: deps.cwd,
2017
- homedir: deps.homedir,
2018
- platform: deps.platform,
2019
- readFile: deps.readFile,
2020
- stat: deps.stat,
2021
- repoName: opts.repo,
2022
- };
2023
- const accessResult = opts.launchProfile !== undefined || opts.baseUrl !== undefined
2024
- ? await resolveEpicLaunchBridgeApiAccess({
2025
- ...(opts.launchProfile ? { profileName: opts.launchProfile } : {}),
2026
- ...(opts.baseUrl ? { baseUrl: opts.baseUrl } : {}),
2027
- }, accessDeps)
2028
- : await resolveConductorBridgeApiAccess(accessDeps);
2029
- if (!accessResult.ok) {
2030
- deps.errorLog(`Cannot reach the Bridge API: ${accessResult.error}`);
2031
- return 1;
2032
- }
2033
- const access = accessResult.access;
2034
- say(`Epic: ${opts.epicKey}`);
2035
- say(`Repo: ${access.repoName}`);
2036
- say(`Plan: v${plan.plan_version}, ${plan.nodes.length} node(s), ${plan.edges.length} edge(s)`);
2037
- say(`Local hash: ${localHash}`);
2038
- for (const w of warnings)
2039
- say(` [warn] ${w}`);
2040
2158
  // --- Branch strategy (BAPI-1009) ----------------------------------------
2041
2159
  // Already resolved above, before the network was touched. Reported HERE, ahead
2042
2160
  // of any secondary policy detail, because it is the one decision that changes
@@ -2093,6 +2211,11 @@ async function executeSetupEpic(argv, overrides, runIdSink) {
2093
2211
  reviewPolicy: opts.reviewPolicy,
2094
2212
  mergeExecutionMode: declaredMergeExecutionMode,
2095
2213
  });
2214
+ const selectedEffectivePolicyFacts = effectivePolicyFacts(existingRunId !== null ? existingPolicyJson : composedForCreate, selectedPolicySource);
2215
+ say(`Policy: effective ${selectedEffectivePolicyFacts.source}; serial execution; ` +
2216
+ `caps ${selectedEffectivePolicyFacts.caps ? JSON.stringify(selectedEffectivePolicyFacts.caps) : "unavailable"}; ` +
2217
+ `timeouts ${selectedEffectivePolicyFacts.job_timeouts ? JSON.stringify(selectedEffectivePolicyFacts.job_timeouts) : "unavailable"}; ` +
2218
+ `channel ${selectedEffectivePolicyFacts.escalation_channel}`);
2096
2219
  // BAPI-1133 (A2). Reported only when THIS command stamped the key — i.e. it
2097
2220
  // is present in the composed policy and was absent from whatever the
2098
2221
  // operator supplied — as low-emphasis policy metadata, mirroring the
@@ -2128,59 +2251,6 @@ async function executeSetupEpic(argv, overrides, runIdSink) {
2128
2251
  say(`Feature: ${effectiveFeatureBranch} (from the policy file; cut on origin with local ` +
2129
2252
  `git at the canonical indexed commit; validated server-side at approval)`);
2130
2253
  }
2131
- // --- Step 0: pre-check ---------------------------------------------------
2132
- // Never create on an ambiguous read. A wrong answer here mints a duplicate run.
2133
- let existingRunId = null;
2134
- let existingStatus = null;
2135
- let existingBaseBranch = null;
2136
- let existingReviewPolicy = null;
2137
- // BAPI-754: the live run's SERVER-REDACTED stored policy, kept for the reuse
2138
- // comparison. Redacted is the only form a client can ever see.
2139
- let existingPolicyJson = null;
2140
- try {
2141
- const state = await fetchEpicRunState(access, opts.epicKey, deps.fetch);
2142
- existingRunId = state.epic_run?.epic_run_id ?? null;
2143
- existingStatus = state.epic_run?.status ?? null;
2144
- const existingPolicy = state.epic_run?.policy_json;
2145
- existingPolicyJson = existingPolicy ?? null;
2146
- const existingBase = existingPolicy && typeof existingPolicy === "object"
2147
- ? existingPolicy.base_branch
2148
- : undefined;
2149
- existingBaseBranch = typeof existingBase === "string" && existingBase.trim() !== ""
2150
- ? existingBase
2151
- : null;
2152
- // BAPI-679: read the existing run's review policy for the conflict guard
2153
- // below. Absent/malformed reads as null — "the resolver's default applies" —
2154
- // which is deliberately NOT the same as an explicitly selected source.
2155
- const existingReview = existingPolicy && typeof existingPolicy === "object"
2156
- ? existingPolicy.review_policy
2157
- : undefined;
2158
- const existingReviewSource = existingReview && typeof existingReview === "object"
2159
- ? existingReview.source
2160
- : undefined;
2161
- existingReviewPolicy =
2162
- typeof existingReviewSource === "string" && existingReviewSource.trim() !== ""
2163
- ? existingReviewSource
2164
- : null;
2165
- }
2166
- catch (err) {
2167
- if (err instanceof ConductorBridgeApiError && err.status === 404) {
2168
- existingRunId = null; // No live run — the normal first-run path.
2169
- }
2170
- else if (err instanceof ConductorBridgeApiError && err.status === 409) {
2171
- deps.errorLog(`Epic ${opts.epicKey} has MULTIPLE active runs — it is wedged, and every ` +
2172
- `plan call will keep failing. Abandon the duplicate before retrying:\n` +
2173
- ` epic-implementer abandon-run --epic-run-id <duplicate_epic_run_id>\n` +
2174
- `Detail: ${errorDetail(err)}`);
2175
- return 1;
2176
- }
2177
- else {
2178
- deps.errorLog(`Could not read the epic run state, so creating one would risk a duplicate ` +
2179
- `(which wedges the epic permanently). Refusing to continue.\n` +
2180
- `Detail: ${errorDetail(err)}`);
2181
- return 1;
2182
- }
2183
- }
2184
2254
  // --- Feature-branch conflict guard against an existing live run ----------
2185
2255
  // A selected branch that disagrees with the existing run's stored base branch
2186
2256
  // must fail closed rather than patch or silently change that run. A matching
@@ -2508,6 +2578,7 @@ async function executeSetupEpic(argv, overrides, runIdSink) {
2508
2578
  // "absent"; server_validated says whether the check was authoritative.
2509
2579
  server_validated: validated !== null,
2510
2580
  policy_applied: "absent",
2581
+ effective_policy: selectedEffectivePolicyFacts,
2511
2582
  ...(validated
2512
2583
  ? {
2513
2584
  server_plan_hash: validated.planHash,
@@ -2579,6 +2650,7 @@ async function executeSetupEpic(argv, overrides, runIdSink) {
2579
2650
  plan_approved: false,
2580
2651
  server_validated: validated !== null,
2581
2652
  policy_applied: "absent",
2653
+ effective_policy: selectedEffectivePolicyFacts,
2582
2654
  warnings,
2583
2655
  };
2584
2656
  // BAPI-850: the EFFECTIVE feature branch (flag or policy file), so a
@@ -3128,14 +3200,14 @@ async function executeSetupEpic(argv, overrides, runIdSink) {
3128
3200
  else {
3129
3201
  say("The server-side reconciler will pick it up within ~30s.");
3130
3202
  }
3131
- // BAPI-1102 — the executor command is printed WITH a claim scope. Since
3132
- // BAPI-1026 an executor started with no scope REFUSES TO START, so the
3133
- // unscoped form printed here was guidance that could not work: an operator
3134
- // who copied it got a startup error, and the natural reading of that error is
3135
- // that the run is broken rather than that the instruction was.
3136
- say("To execute this run's claimed jobs on this machine, run:");
3137
- say(` npx -y ${MCP_PACKAGE_NAME} executor --repo ${access.repoName} ` +
3138
- `--epic-run-id ${result.epic_run_id}`);
3203
+ if (handoff) {
3204
+ say("The launcher starts or reuses the executor appropriate for this run.");
3205
+ }
3206
+ else {
3207
+ say("To execute this run's claimed jobs on this machine, run:");
3208
+ say(` npx -y ${MCP_PACKAGE_NAME} executor --repo ${access.repoName} ` +
3209
+ `--epic-run-id ${result.epic_run_id}`);
3210
+ }
3139
3211
  }
3140
3212
  return exitCode;
3141
3213
  }