@bridge_gpt/mcp-server 0.2.51 → 0.2.52

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 (67) hide show
  1. package/README.md +24 -8
  2. package/build/agent-capabilities/probe-context.js +15 -7
  3. package/build/agent-capabilities/probes.js +42 -6
  4. package/build/agent-launchers/claude-executor-adapter.js +98 -14
  5. package/build/commands.generated.js +1 -1
  6. package/build/conduct-epic/cut-protocol.js +17 -3
  7. package/build/conductor/bridge-api-client.js +171 -5
  8. package/build/conductor/deny-enforcement-preflight.js +107 -10
  9. package/build/conductor/local-merge.js +170 -11
  10. package/build/conductor-bin.js +2 -2
  11. package/build/connect-bitbucket-api.js +370 -0
  12. package/build/connect-bitbucket.js +437 -0
  13. package/build/docs.generated.js +1 -1
  14. package/build/doctor.js +40 -1
  15. package/build/drive-epic.js +423 -11
  16. package/build/env-file-link.js +164 -0
  17. package/build/epic-integration-pr.js +10 -0
  18. package/build/executor/cli.js +41 -6
  19. package/build/executor/deps.js +5 -1
  20. package/build/executor/env-file-guard.js +113 -0
  21. package/build/executor/env.js +78 -1
  22. package/build/executor/heartbeat.js +9 -0
  23. package/build/executor/http-client.js +90 -22
  24. package/build/executor/job-errors.js +43 -2
  25. package/build/executor/job-runner.js +130 -28
  26. package/build/executor/merge-job.js +67 -16
  27. package/build/executor/permissions.js +106 -0
  28. package/build/executor/preflight.js +38 -13
  29. package/build/executor/resume-pre-spawn.js +2 -1
  30. package/build/executor/runner.js +175 -4
  31. package/build/executor/service-unit.js +15 -0
  32. package/build/executor/terminal-mutation.js +22 -1
  33. package/build/executor/types.js +86 -0
  34. package/build/executor/worker-command.js +21 -5
  35. package/build/executor/worker-guard-hook.js +939 -0
  36. package/build/executor/worker-log.js +56 -0
  37. package/build/executor/worktree.js +11 -0
  38. package/build/git-reachability.js +147 -0
  39. package/build/index.js +514 -121
  40. package/build/install-bridge.js +95 -0
  41. package/build/pipelines.generated.js +5 -3
  42. package/build/plan-epic-conductor-eligibility.js +37 -7
  43. package/build/plane/cli.js +78 -15
  44. package/build/plane/defaults.js +165 -0
  45. package/build/plane/manifest.js +63 -8
  46. package/build/plane/member-logs.js +6 -0
  47. package/build/plane/member-roster.js +195 -11
  48. package/build/plane/preflight.js +43 -0
  49. package/build/plane/shutdown.js +25 -3
  50. package/build/plane/status.js +11 -0
  51. package/build/plane/supervisor.js +343 -14
  52. package/build/plane/test-fakes.js +43 -0
  53. package/build/plane/types.js +82 -11
  54. package/build/pr-base-contract.js +20 -0
  55. package/build/readme.generated.js +1 -1
  56. package/build/review-synthesis-config.js +60 -0
  57. package/build/scripts/executor-protocol-contract-driver.js +311 -0
  58. package/build/setup-epic.js +560 -139
  59. package/build/sfcc/log-query.js +2 -1
  60. package/build/start-tickets-conductor.js +11 -2
  61. package/build/start-tickets.js +69 -2
  62. package/build/version.generated.js +3 -3
  63. package/build/worker-containment-diagnostic.js +97 -0
  64. package/build/worker-guard-hook-bin.js +6 -0
  65. package/docs/CONDUCTOR.md +27 -0
  66. package/docs/install/mcp-tool-integrations.md +3 -2
  67. package/package.json +3 -2
@@ -30,9 +30,15 @@
30
30
  * file-overlap serialization, so a divergence is legitimate, not corruption.
31
31
  * 5. Store, then approve.
32
32
  *
33
+ * BAPI-1009 — the epic branch is the DEFAULT for a multi-ticket epic. A plan
34
+ * with two or more nodes and no branch flag derives `epic/<KEY>` on EVERY path
35
+ * (piped, CI, and `--json` included), so the scope-bearing sequence below is now
36
+ * the ordinary path rather than an opt-in one. A single-node plan stays on the
37
+ * repository base branch, and `--into-base` forces base for any node count.
38
+ *
33
39
  * BAPI-850 — a FEATURE-BRANCH run (`policy_json.base_branch` set, whether by
34
- * `--feature-branch` or by `--policy-file`) performs the full scope-bearing
35
- * start sequence in this one invocation, in this order:
40
+ * the BAPI-1009 default, `--feature-branch`, or `--policy-file`) performs the
41
+ * full scope-bearing start sequence in this one invocation, in this order:
36
42
  *
37
43
  * a. Read canonical parse truth (`GET /jira/parse-status`). No succeeded
38
44
  * parse or no indexed commit → exit 1 with "parse the repository first";
@@ -47,13 +53,18 @@
47
53
  * d. Store and approve (unchanged requests). The server now READ-validates the
48
54
  * ref against the scope's recorded cut and schedules the seed + verifying
49
55
  * parse in the background; the run goes `active` immediately.
50
- * e. Poll the scope's lifecycle with a bounded wait, reporting
51
- * `Provisioning Seeding Verifying → Ready`. `failed` and a timeout
52
- * both exit 1; the reconciler's dispatch-freshness hold keeps an
53
- * un-ready scope from dispatching either way.
56
+ * e. Poll the scope's lifecycle with a SHORT bounded wait (BAPI-1054:
57
+ * {@link SETUP_EPIC_SCOPE_DEFERRED_MAX_POLLS} polls, ~30s), reporting
58
+ * `Provisioning Seeding Verifying Ready`. A scope still preparing at
59
+ * that deadline is acknowledged as `created, cut deferred` and exits 0 —
60
+ * the run is created and approved, and preparation continues server-side.
61
+ * Only `failed` and `ready_mismatch` are terminal and exit 1. The
62
+ * reconciler's dispatch-freshness hold keeps an un-ready scope from
63
+ * dispatching in every case, which is what makes the deferred return safe.
54
64
  *
55
- * A run with no feature branch is byte-identical to before: no parse read, no
56
- * `git`, no scope, no polling, no extra JSON keys.
65
+ * A run with no feature branch a single-node plan, or `--into-base` is
66
+ * byte-identical to before: no parse read, no `git`, no scope, no polling, no
67
+ * extra JSON keys.
57
68
  *
58
69
  * Reads the plan sidecar and the optional policy file from the local
59
70
  * filesystem. As of BAPI-872 it can also WRITE the local filesystem in one
@@ -66,9 +77,9 @@
66
77
  */
67
78
  import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
68
79
  import os from "node:os";
69
- import readline from "node:readline";
70
80
  import { createProductionEpicIntegrationGhRunner, ensureEpicIntegrationPullRequest, formatEpicIntegrationPullRequestOutcome, } from "./epic-integration-pr.js";
71
81
  import { approveEpicPlan, createEpicRunWithDisposition, fetchEpicRunState, replaceEpicRunPolicy, resolveConductorBridgeApiAccess, storeEpicPlan, validateEpicPlan, ConductorBridgeApiError, } from "./conductor/bridge-api-client.js";
82
+ import { resolveTrackedPaths } from "./git-reachability.js";
72
83
  import { validateBranchName } from "./base-ref.js";
73
84
  // Reused from the conduct-epic client on purpose: adding a second wrapper under
74
85
  // `conductor/` would give the v2 path its own copy to drift from.
@@ -79,7 +90,7 @@ import { validateBranchName } from "./base-ref.js";
79
90
  // conductor can drift on a field name or an endpoint shape. No MCP tool, no
80
91
  // input-schema field, and no description changes.
81
92
  import { getConfigFieldBaseBranch, getIndexScopeLifecycle, getParseStatus, } from "./conduct-epic/bridge-client.js";
82
- import { createExecFileRunCommand, ensureCommitResolvableLocally, normalizeCommitSha, performExactIndexScopeCut, pollIndexScopeLifecycle, readRemoteBranchHead, runGit, SCOPE_LIFECYCLE_LABELS, } from "./conduct-epic/cut-protocol.js";
93
+ import { boundScopeLifecycleLabel, createExecFileRunCommand, ensureCommitResolvableLocally, normalizeCommitSha, performExactIndexScopeCut, pollIndexScopeLifecycle, readRemoteBranchHead, runGit, SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, SCOPE_LIFECYCLE_LABELS, } from "./conduct-epic/cut-protocol.js";
83
94
  import { hashPlan } from "./conductor/plan.js";
84
95
  import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
85
96
  // BAPI-872: local plane-manifest binding, so `plane down` can later stop the
@@ -93,25 +104,6 @@ export const SETUP_EPIC_REVIEW_POLICY_SOURCES = [
93
104
  "native_review_decision",
94
105
  "none",
95
106
  ];
96
- /** Echoed single-line prompt on stderr (mirrors connect-github's helper). */
97
- function defaultPromptLine(promptText) {
98
- return new Promise((resolve) => {
99
- const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
100
- // `rl.close()` emits `close` SYNCHRONOUSLY, so without this guard the close
101
- // handler would settle the promise empty and discard a real answer. EOF must
102
- // also resolve rather than deadlock a top-level await.
103
- let answered = false;
104
- rl.on("close", () => {
105
- if (!answered)
106
- resolve("");
107
- });
108
- rl.question(promptText, (answer) => {
109
- answered = true;
110
- rl.close();
111
- resolve(answer.trim());
112
- });
113
- });
114
- }
115
107
  export function createDefaultSetupEpicDeps() {
116
108
  return {
117
109
  env: process.env,
@@ -123,8 +115,6 @@ export function createDefaultSetupEpicDeps() {
123
115
  fetch: globalThis.fetch,
124
116
  log: (m) => console.log(m),
125
117
  errorLog: (m) => console.error(m),
126
- isTTY: Boolean(process.stdin.isTTY),
127
- promptLine: defaultPromptLine,
128
118
  runCommand: createExecFileRunCommand(),
129
119
  epicIntegrationGh: createProductionEpicIntegrationGhRunner(),
130
120
  planeBinding: {
@@ -142,6 +132,12 @@ export function getSetupEpicUsage() {
142
132
  "Bootstraps an Epic Conductor v2 run: creates the run, stores the plan DAG,",
143
133
  "and approves it. Idempotent — re-running reuses an existing live run.",
144
134
  "",
135
+ "Branch strategy (default): a plan with TWO OR MORE nodes runs on a dedicated",
136
+ "epic branch named epic/<KEY>, derived automatically on every path (piped, CI,",
137
+ "and --json included). Every child-ticket PR targets epic/<KEY>, and one DRAFT",
138
+ "integration PR targets the repository base branch. A single-node plan keeps the",
139
+ "repository base branch, matching plain start-tickets semantics.",
140
+ "",
145
141
  "Required:",
146
142
  " --epic-key <KEY> Jira epic key (e.g. BAPI-405)",
147
143
  " --plan-file <path> Path to epic-plan.dag.json (from decompose-epic)",
@@ -149,15 +145,26 @@ export function getSetupEpicUsage() {
149
145
  "Options:",
150
146
  " --repo <name> Repo name (default: BAPI_REPO_NAME or .bridge/config)",
151
147
  " --plan-version <n> Assert the sidecar's plan_version equals <n>",
152
- " --feature-branch <name> Run the epic on a dedicated feature branch.",
148
+ " --feature-branch <name> OVERRIDE the derived epic/<KEY> name with your own.",
153
149
  " setup-epic cuts the branch on origin with your LOCAL git",
154
150
  " at exactly the repository's canonical indexed commit",
155
151
  " (the repository must be parsed first), the server",
156
152
  " validates it, and every child-ticket PR targets it.",
157
- " setup-epic then waits (bounded) for the run's index",
158
- " scope to become ready. Omit (the default) to continue",
159
- " on the repository base branch. Interactive runs are",
160
- " offered a proposal.",
153
+ " setup-epic then waits BRIEFLY (about " +
154
+ `${SETUP_EPIC_SCOPE_DEFERRED_WINDOW_SECONDS}s) for the run's index scope to`,
155
+ " become ready. If it is still preparing at that point the",
156
+ ` command exits 0 with a '${SETUP_EPIC_CUT_DEFERRED_PHRASE}'`,
157
+ " acknowledgement naming the run and scope: the run IS",
158
+ " created and approved, preparation continues server-side,",
159
+ " and the reconciler holds ticket dispatch until it is",
160
+ " ready. Only a recorded scope failure exits non-zero.",
161
+ " Also forces an epic branch for a SINGLE-node plan, which",
162
+ " the default would leave on base.",
163
+ " --into-base OPT OUT of the epic-branch default: cut no branch and",
164
+ " target every child PR at the repository base branch",
165
+ " (the pre-BAPI-1009 behavior). Use this if automation",
166
+ " depended on \"no flag = direct to base\". Contradictory",
167
+ " with --feature-branch; passing both is a parse error.",
161
168
  " --review-policy <src> PER-RUN review policy source, one of:",
162
169
  ` ${SETUP_EPIC_REVIEW_POLICY_SOURCES.join(", ")}.`,
163
170
  " Composed into policy_json.review_policy on create.",
@@ -256,6 +263,12 @@ export function parseSetupEpicArgs(argv) {
256
263
  let repo;
257
264
  let planVersion;
258
265
  let featureBranch;
266
+ // BAPI-1009: tracked SEPARATELY from `featureBranch`, because an explicitly
267
+ // blank `--feature-branch=` normalizes the value to `undefined` while still
268
+ // being a supplied branch-strategy intent. The contradiction guard below must
269
+ // fire on the INTENT, not on whether a name survived normalization.
270
+ let featureBranchSupplied = false;
271
+ let intoBase = false;
259
272
  let reviewPolicy;
260
273
  let policyFile;
261
274
  let replacePolicy = false;
@@ -278,6 +291,7 @@ export function parseSetupEpicArgs(argv) {
278
291
  if (!parsedFb.ok)
279
292
  return { status: "error", message: parsedFb.error };
280
293
  featureBranch = parsedFb.value;
294
+ featureBranchSupplied = true;
281
295
  continue;
282
296
  }
283
297
  // `--review-policy=<source>` inline form (BAPI-679).
@@ -298,6 +312,7 @@ export function parseSetupEpicArgs(argv) {
298
312
  if (!parsedFb.ok)
299
313
  return { status: "error", message: parsedFb.error };
300
314
  featureBranch = parsedFb.value;
315
+ featureBranchSupplied = true;
301
316
  i++;
302
317
  break;
303
318
  }
@@ -361,6 +376,9 @@ export function parseSetupEpicArgs(argv) {
361
376
  i++;
362
377
  break;
363
378
  }
379
+ case "--into-base":
380
+ intoBase = true;
381
+ break;
364
382
  case "--replace-policy":
365
383
  replacePolicy = true;
366
384
  break;
@@ -395,6 +413,18 @@ export function parseSetupEpicArgs(argv) {
395
413
  "on a real setup.",
396
414
  };
397
415
  }
416
+ // BAPI-1009: the two branch-strategy flags are contradictory intents, refused
417
+ // HERE alongside the other pre-flight contradictions — before any file read,
418
+ // credential resolution, git command, or network request.
419
+ if (featureBranchSupplied && intoBase) {
420
+ return {
421
+ status: "error",
422
+ message: "--feature-branch and --into-base select contradictory branch strategies: " +
423
+ "--feature-branch runs the epic on a named epic branch, --into-base runs " +
424
+ "children directly into the repository base branch. Only one branch strategy " +
425
+ "may be selected — remove whichever one you did not mean.",
426
+ };
427
+ }
398
428
  if (replacePolicy && policyFile === undefined) {
399
429
  return {
400
430
  status: "error",
@@ -411,6 +441,7 @@ export function parseSetupEpicArgs(argv) {
411
441
  repo,
412
442
  planVersion,
413
443
  featureBranch,
444
+ intoBase,
414
445
  reviewPolicy,
415
446
  policyFile,
416
447
  replacePolicy,
@@ -524,10 +555,16 @@ export function composeSetupEpicPolicy(filePolicy, selections) {
524
555
  };
525
556
  }
526
557
  if (fileReview.kind === "source" && fileReview.value !== selections.reviewPolicy) {
558
+ // BAPI-1054: the file's value is NOT echoed. `review_policy.source` is an
559
+ // arbitrary string on the wire, read straight out of an operator-authored
560
+ // file that can also carry `notify.webhook_url` — so interpolating it put
561
+ // unbounded file-derived text into stderr and CI logs. Naming the field and
562
+ // the CLI selection (which came from a closed vocabulary and is therefore
563
+ // safe) says everything the operator needs to fix it.
527
564
  return {
528
565
  ok: false,
529
566
  error: `--review-policy selected '${selections.reviewPolicy}', but --policy-file ` +
530
- `declares review_policy.source '${fileReview.value}'. setup-epic will not pick ` +
567
+ `declares a different review_policy.source. setup-epic will not pick ` +
531
568
  `a winner between them: drop the flag, or fix the policy file so both agree.`,
532
569
  };
533
570
  }
@@ -541,6 +578,53 @@ export function composeSetupEpicPolicy(filePolicy, selections) {
541
578
  }
542
579
  return { ok: true, policy: composed };
543
580
  }
581
+ /**
582
+ * The single named policy gate, run before any credential resolution or request.
583
+ *
584
+ * Pure, and deterministic in its refusal order: it reports the FIRST violation
585
+ * and stops, so one invocation yields one named reason. It consolidates checks
586
+ * that already existed but were scattered across two call sites, which is what
587
+ * let `--local-only --dry-run` and an ordinary run evaluate slightly different
588
+ * sets — a dry run is worth nothing if it does not refuse what the real run will.
589
+ *
590
+ * It is READ-ONLY with respect to policy content: nothing here normalizes,
591
+ * coerces, deletes, or reconstructs a policy file's keys.
592
+ * {@link composeSetupEpicPolicy} remains the only function that adds validated
593
+ * CLI selections, and it is called from here so the object that passes the gate
594
+ * is exactly the object the request later carries.
595
+ *
596
+ * Every message is secret-free by construction: fixed prose, the flag names, the
597
+ * file PATH, and CLI-supplied values that came from a closed vocabulary. No
598
+ * message interpolates a value read out of the policy file.
599
+ */
600
+ export function preflightSetupEpicPolicy(args) {
601
+ // 1. Branch strategy. `--feature-branch` versus `--into-base` is already a
602
+ // parse error, refused before a file is even read; this is the same
603
+ // contradiction spelled across two sources instead of two flags.
604
+ if (args.intoBase && args.policyDeclaresBranch) {
605
+ return {
606
+ ok: false,
607
+ reason: "branch_strategy_contradiction",
608
+ message: `--into-base runs children directly into the repository base branch, but ` +
609
+ `--policy-file '${args.policyFile}' declares a base_branch, which asks for an ` +
610
+ `epic branch. setup-epic will not pick a winner between them: remove the ` +
611
+ `branch declaration from the policy file, or drop --into-base.`,
612
+ };
613
+ }
614
+ // 2. Policy-source contradictions between the file and the CLI selections.
615
+ // Delegated to the composer, which owns the "no silent winner" rule; the
616
+ // gate only gives the outcome a stable name.
617
+ if (args.filePolicy === undefined)
618
+ return { ok: true, policy: undefined };
619
+ const composed = composeSetupEpicPolicy(args.filePolicy, {
620
+ featureBranch: args.featureBranch,
621
+ reviewPolicy: args.reviewPolicy,
622
+ });
623
+ if (!composed.ok) {
624
+ return { ok: false, reason: "policy_source_contradiction", message: composed.error };
625
+ }
626
+ return { ok: true, policy: composed.policy };
627
+ }
544
628
  /**
545
629
  * Does this policy object explicitly supply the `notify.webhook_url` leaf?
546
630
  *
@@ -775,6 +859,64 @@ export function validateEpicPlanSidecar(parsed) {
775
859
  }
776
860
  return { ok: true, plan: parsed, warnings };
777
861
  }
862
+ /**
863
+ * The cited-document paths the server reported it could NOT resolve (BAPI-1027).
864
+ *
865
+ * Read off the server's own notice rather than re-derived from the plan: the
866
+ * server decides which citations matter and which it still needs evidence for,
867
+ * so the CLI resolves exactly what it was asked for and nothing more. That is
868
+ * what keeps the rule un-mirrored — the CLI reads a field, it does not reimplement
869
+ * a check.
870
+ */
871
+ export function unresolvedCitedDocPaths(result) {
872
+ const paths = [];
873
+ const seen = new Set();
874
+ for (const notice of result.notices) {
875
+ if (notice.noticeCode !== CITED_DOCUMENT_REACHABILITY_NOT_EVALUATED)
876
+ continue;
877
+ for (const path of notice.paths) {
878
+ if (seen.has(path))
879
+ continue;
880
+ seen.add(path);
881
+ paths.push(path);
882
+ }
883
+ }
884
+ return paths;
885
+ }
886
+ /** The server-side notice code naming citations that still need evidence. */
887
+ const CITED_DOCUMENT_REACHABILITY_NOT_EVALUATED = "CITED_DOCUMENT_REACHABILITY_NOT_EVALUATED";
888
+ /** The server-side TERMINAL duplicate-active-run code (BAPI-1027). */
889
+ export const ACTIVE_EPIC_RUN_EXISTS_CODE = "ACTIVE_EPIC_RUN_EXISTS";
890
+ /**
891
+ * True for a RETRYABLE optimistic-concurrency conflict (BAPI-1016 / BAPI-1027).
892
+ *
893
+ * Two conflict families reach this CLI and they call for opposite advice. A
894
+ * stale-CAS `row_version` means "re-read and retry"; a duplicate active run means
895
+ * "close or adopt, retrying is futile". They are told apart by the server's own
896
+ * `error_code`, never by HTTP status — the terminal one is a 409 and the
897
+ * retryable one is a 400 on these routes, which is precisely the sort of
898
+ * incidental detail that must not be what a renderer depends on.
899
+ */
900
+ export function isRetryableConflict(err) {
901
+ return err instanceof ConductorBridgeApiError && err.errorCode === "CONFLICT";
902
+ }
903
+ /**
904
+ * Render the ` (BAPI-1, BAPI-2 · docs/x.md)` subject of one rejection line.
905
+ *
906
+ * Kept to identifiers the operator needs to act — node keys, a path, a revision
907
+ * identifier. Specification content, credentials, and raw HTTP bodies never
908
+ * appear here.
909
+ */
910
+ export function renderRejectionSubject(rejection) {
911
+ const parts = [];
912
+ if (rejection.ticketKeys.length > 0)
913
+ parts.push(rejection.ticketKeys.join(", "));
914
+ if (rejection.migrationId)
915
+ parts.push(`revision ${rejection.migrationId}`);
916
+ else if (rejection.path)
917
+ parts.push(rejection.path);
918
+ return parts.length > 0 ? ` (${parts.join(" · ")})` : "";
919
+ }
778
920
  /** Iterative DFS cycle detection; returns the offending path or null. */
779
921
  function findCycle(keys, adjacency) {
780
922
  const WHITE = 0;
@@ -809,58 +951,85 @@ function findCycle(keys, adjacency) {
809
951
  return null;
810
952
  }
811
953
  /** Propose a conductor epic feature branch name for an epic key (BAPI-655). */
812
- function proposeFeatureBranchName(epicKey) {
954
+ export function proposeFeatureBranchName(epicKey) {
813
955
  return `epic/${epicKey}`;
814
956
  }
957
+ /**
958
+ * The plan-node count at or above which a no-flag run derives its own epic
959
+ * branch (BAPI-1009, R-4).
960
+ *
961
+ * A single-node plan is not an epic in any meaningful sense — it is one ticket,
962
+ * and plain `start-tickets` puts one ticket's PR straight onto the repository
963
+ * base branch. Cutting an `epic/<KEY>` branch, seeding a per-epic index scope,
964
+ * and opening an integration PR for it would add a whole provisioning sequence
965
+ * (and a second PR to merge) for no isolation benefit. Two or more nodes is
966
+ * where children can depend on one another, which is exactly the case the epic
967
+ * branch exists to serve.
968
+ */
969
+ export const EPIC_BRANCH_MIN_PLAN_NODES = 2;
815
970
  /**
816
971
  * Resolve the epic feature branch selection for this invocation.
817
972
  *
818
- * Returns a validated branch name, or `undefined` to continue on the repository
819
- * base branch. Precedence:
973
+ * Returns a branch name, or `undefined` to continue on the repository base
974
+ * branch. Pure and synchronous: BAPI-1009 removed the interactive proposal, so
975
+ * this asks nothing and reads no I/O.
820
976
  *
821
- * - An explicit `--feature-branch <name>` is already confirmed + validated at
822
- * parse time and is returned unchanged (no prompt).
823
- * - A non-interactive (piped/CI) run or a `--json` run never prompts and never
824
- * blocks it retains the absent-branch behavior.
825
- * - A `--policy-file` that already declares a branch has ALREADY answered this
826
- * question (BAPI-754). Prompting anyway would invite an operator to type a
827
- * competing value that composition would then have to refuse.
828
- * - An interactive run is shown the proposal (branch name, "create from the
829
- * repository base branch" strategy, and the child-PR-targeting consequence)
830
- * and may accept it, type a custom name, or decline to use the base branch.
831
- * An edited nonblank value is re-validated; an invalid value is redisplayed
832
- * for correction rather than silently rewritten.
977
+ * Precedence, in order:
978
+ *
979
+ * 1. `--into-base` returns `undefined` unconditionally. It is the explicit
980
+ * opt-out, and it wins over the derived default for every node count.
981
+ * 2. An explicit `--feature-branch <name>` is already confirmed + validated at
982
+ * parse time and is returned unchanged. It also forces an epic branch for a
983
+ * SINGLE-node plan, which rule 4 would otherwise leave on base.
984
+ * 3. A `--policy-file` that already declares a branch has ALREADY answered this
985
+ * question (BAPI-754), so no CLI branch is added composition would then
986
+ * have to arbitrate between two answers.
987
+ * 4. A plan with fewer than {@link EPIC_BRANCH_MIN_PLAN_NODES} nodes returns
988
+ * `undefined` (R-4).
989
+ * 5. Otherwise the deterministic {@link proposeFeatureBranchName} value.
990
+ *
991
+ * BAPI-1009 deliberately removed the TTY/`--json` early return that used to
992
+ * produce `undefined` here. Branch strategy that depended on whether stdout was
993
+ * a terminal meant a CI run and an operator's run of the SAME command bootstrapped
994
+ * two different topologies — and the CI one was the one the shadow-index bootstrap
995
+ * was never exercised against.
833
996
  */
834
- async function resolveFeatureBranchSelection(opts, repoName, deps, policyDeclaresBranch = false) {
997
+ export function resolveFeatureBranchSelection(opts, planNodeCount, policyDeclaresBranch = false) {
998
+ if (opts.intoBase)
999
+ return undefined;
835
1000
  if (opts.featureBranch !== undefined)
836
1001
  return opts.featureBranch;
837
1002
  if (policyDeclaresBranch)
838
1003
  return undefined;
839
- if (!deps.isTTY || opts.json)
1004
+ if (planNodeCount < EPIC_BRANCH_MIN_PLAN_NODES)
840
1005
  return undefined;
841
- const proposed = proposeFeatureBranchName(opts.epicKey);
842
- deps.errorLog("");
843
- deps.errorLog(`Feature branch (optional) for epic ${opts.epicKey} on ${repoName}:`);
844
- deps.errorLog(` Proposed: ${proposed}`);
845
- deps.errorLog(` Strategy: create a new branch from the repository base branch`);
846
- deps.errorLog(` Effect: every child-ticket PR will target this branch`);
847
- for (;;) {
848
- const answer = (await deps.promptLine(`Use feature branch? 'y' = ${proposed}, a name = custom, Enter = base branch: `)).trim();
849
- if (answer === "")
850
- return undefined; // decline → repository base branch
851
- const lowered = answer.toLowerCase();
852
- if (lowered === "n" || lowered === "no")
853
- return undefined;
854
- if (lowered === "y" || lowered === "yes")
855
- return proposed;
856
- // Otherwise the answer is an edited branch name.
857
- const reason = validateBranchName(answer);
858
- if (reason) {
859
- deps.errorLog(` Invalid branch name: ${reason} Try again, or press Enter for the base branch.`);
860
- continue; // redisplay for correction; never silently rewrite the value
861
- }
862
- return answer;
1006
+ return proposeFeatureBranchName(opts.epicKey);
1007
+ }
1008
+ /**
1009
+ * One line naming the selected branch strategy AND why it was selected.
1010
+ *
1011
+ * BAPI-1009: the "why" is the load-bearing half. The default changed, so an
1012
+ * operator reading `epic/BAPI-1009` needs to know whether they asked for it,
1013
+ * their policy file asked for it, or the multi-node default derived it those
1014
+ * three have different recovery steps when the name is not the one they wanted.
1015
+ */
1016
+ function describeBranchStrategy(opts, featureBranch, policyDeclaresBranch) {
1017
+ const cutNote = "cut on origin with local git at the canonical indexed commit; " +
1018
+ "validated server-side at approval";
1019
+ if (featureBranch !== undefined) {
1020
+ if (opts.featureBranch !== undefined) {
1021
+ return `${featureBranch} (explicit --feature-branch override; ${cutNote})`;
1022
+ }
1023
+ return `${featureBranch} (derived epic branch for this multi-ticket plan; ${cutNote})`;
863
1024
  }
1025
+ if (opts.intoBase) {
1026
+ return "repository base branch — epic branch disabled by --into-base; every child PR targets base";
1027
+ }
1028
+ if (policyDeclaresBranch) {
1029
+ return "from the policy file's base_branch (the file already answered this)";
1030
+ }
1031
+ return (`repository base branch — a single-node plan keeps base, matching start-tickets ` +
1032
+ `(pass --feature-branch to force ${proposeFeatureBranchName(opts.epicKey)} anyway)`);
864
1033
  }
865
1034
  function errorDetail(err) {
866
1035
  if (err instanceof ConductorBridgeApiError) {
@@ -941,6 +1110,36 @@ function emitRefusal(deps, opts, result) {
941
1110
  * the newer commit.
942
1111
  */
943
1112
  export const SETUP_EPIC_SCOPE_RECOVERY_COMMAND = "re-run this same setup-epic command (approval is idempotent and re-drives the scope's seed and verification)";
1113
+ /**
1114
+ * The canonical one-line recovery for a scope collision (BAPI-1009).
1115
+ *
1116
+ * Repeated VERBATIM as the epic-run gate snapshot's shadow-hold recovery hint
1117
+ * (``api/library/epic_conductor/gate_snapshot_service.py``), so an operator who
1118
+ * reads the diagnosis on the status surface and an operator who reads it from
1119
+ * the CLI are told to do the same thing in the same words.
1120
+ */
1121
+ export const SETUP_EPIC_SCOPE_REDRIVE_RECOVERY = "Re-drive the cut at the newer commit.";
1122
+ /**
1123
+ * How long `setup-epic` waits for a scope cut before acknowledging it as
1124
+ * DEFERRED (BAPI-1054).
1125
+ *
1126
+ * Six polls at the shared 5-second interval — roughly 30 seconds. It is a fixed
1127
+ * value, injectable for tests but deliberately NOT operator-configurable, so
1128
+ * "returns promptly" is one testable number rather than a range that depends on
1129
+ * how the operator invoked the command.
1130
+ *
1131
+ * It bounds THIS caller only. {@link SCOPE_BOOTSTRAP_MAX_POLLS} is untouched, so
1132
+ * anything else that drives the shared poll keeps the generous default a real
1133
+ * seed needs. Waiting the full ~20 minutes was never buying information: the run
1134
+ * is already created and approved, the reconciler holds dispatch until the scope
1135
+ * is ready either way, and the only thing the long block changed was whether the
1136
+ * operator's terminal was occupied while it happened.
1137
+ */
1138
+ export const SETUP_EPIC_SCOPE_DEFERRED_MAX_POLLS = 6;
1139
+ /** The deferred window in seconds, derived so prose cannot drift from the bound. */
1140
+ export const SETUP_EPIC_SCOPE_DEFERRED_WINDOW_SECONDS = Math.round((SETUP_EPIC_SCOPE_DEFERRED_MAX_POLLS * SCOPE_BOOTSTRAP_POLL_INTERVAL_MS) / 1000);
1141
+ /** The exact phrase the deferred acknowledgement leads with. Named so tests pin it. */
1142
+ export const SETUP_EPIC_CUT_DEFERRED_PHRASE = "created, cut deferred";
944
1143
  /** The lead sentence of every fail-closed canonical-parse refusal (BAPI-850). */
945
1144
  export const SETUP_EPIC_PARSE_FIRST_MESSAGE = "parse the repository first";
946
1145
  /**
@@ -1049,9 +1248,20 @@ async function resolveScopeIdForRun(access, fetchImpl, epicRunId) {
1049
1248
  * ONE run), so it is always named explicitly rather than left as a placeholder.
1050
1249
  */
1051
1250
  function scopeFailureGuidance(reason, featureBranch, epicRunId) {
1251
+ // BAPI-1009: both collision categories lead with the SAME canonical sentence the
1252
+ // run snapshot's recovery hint repeats, then keep their own remediation detail.
1253
+ // The default now cuts a branch where it previously did not, so a collision is
1254
+ // reachable by operators who never chose a branch strategy at all — the recovery
1255
+ // has to read identically wherever they happen to encounter it.
1052
1256
  if (reason === "canonical_index_advanced") {
1053
- return (`The canonical index advanced before the scope could be seeded. The cut must be ` +
1054
- `re-driven at the newer commit: delete origin/${featureBranch}, abandon this run ` +
1257
+ return (`${SETUP_EPIC_SCOPE_REDRIVE_RECOVERY} The canonical index advanced before the ` +
1258
+ `scope could be seeded: delete origin/${featureBranch}, abandon this run ` +
1259
+ `(conductor abandon-run --epic-run-id ${epicRunId}), and re-run setup-epic.`);
1260
+ }
1261
+ if (reason === "head_mismatch") {
1262
+ return (`${SETUP_EPIC_SCOPE_REDRIVE_RECOVERY} origin/${featureBranch} does not sit at the ` +
1263
+ `commit this scope was cut at, and setup-epic never force-updates an existing ` +
1264
+ `ref: delete origin/${featureBranch}, abandon this run ` +
1055
1265
  `(conductor abandon-run --epic-run-id ${epicRunId}), and re-run setup-epic.`);
1056
1266
  }
1057
1267
  return `Recovery: ${SETUP_EPIC_SCOPE_RECOVERY_COMMAND}.`;
@@ -1064,11 +1274,20 @@ function scopeFailureGuidance(reason, featureBranch, epicRunId) {
1064
1274
  * detail: the label leads, the scope id never does, and no credential appears.
1065
1275
  */
1066
1276
  function describeScopeTransition(state, status) {
1067
- const label = SCOPE_LIFECYCLE_LABELS[state] ?? state;
1277
+ // BAPI-1054: bounded on the way in, so an unrecognized server state renders as
1278
+ // `unknown` rather than as itself. `SCOPE_LIFECYCLE_LABELS[state] ?? state`
1279
+ // fell through to the raw value for anything outside the known set.
1280
+ const bounded = boundScopeLifecycleLabel(state);
1281
+ const label = SCOPE_LIFECYCLE_LABELS[bounded] ?? bounded;
1068
1282
  if (state === "ready") {
1069
1283
  return `Scope: ${label} — indexed at ${status.indexed_commit_sha ?? "unknown"} (scope ${status.scope_id})`;
1070
1284
  }
1071
1285
  if (state === "failed") {
1286
+ // `last_error` is the bounded failure CATEGORY, not free text: the terminal
1287
+ // branch already surfaces the same value as `index_scope.failure_reason` and
1288
+ // switches on it in `scopeFailureGuidance`. Dropping it here would lose the
1289
+ // one fact that distinguishes a recoverable collision from a seed failure,
1290
+ // while removing nothing that is not already reported.
1072
1291
  return `Scope: ${label} — ${status.last_error ?? "unknown"} (scope ${status.scope_id})`;
1073
1292
  }
1074
1293
  return `Scope: ${label}…`;
@@ -1135,22 +1354,47 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1135
1354
  }
1136
1355
  const policyDeclaresBranch = filePolicy !== undefined &&
1137
1356
  ["base_branch", "baseBranch"].some((key) => key in filePolicy && filePolicy[key] !== undefined && filePolicy[key] !== null);
1357
+ // --- Feature branch selection (BAPI-655, defaulted by BAPI-1009) ---------
1358
+ // Resolved from the VALIDATED plan's node count, the flags, and the policy
1359
+ // file — all of which are already in hand — and deliberately BEFORE the
1360
+ // `--local-only` return and before credential resolution. Selection reads no
1361
+ // network and asks no question, so running it here is what makes `--local-only`
1362
+ // and an ordinary invocation report the same strategy for the same plan.
1363
+ const featureBranch = resolveFeatureBranchSelection(opts, plan.nodes.length, policyDeclaresBranch);
1364
+ const branchStrategyLine = describeBranchStrategy(opts, featureBranch, policyDeclaresBranch);
1365
+ // --- Consolidated policy preflight (BAPI-1054) ---------------------------
1366
+ // ONE gate, run BEFORE credential resolution and before any request, over the
1367
+ // whole flag-backed choice-set. It sits above the `--local-only` return
1368
+ // deliberately: a dry run that refuses a different set from the real run is
1369
+ // worth very little, and this branch previously composed the policy without
1370
+ // evaluating the `--into-base` contradiction alongside it.
1371
+ //
1372
+ // The server's `RunPolicy` schema stays authoritative. This validates the
1373
+ // choices the CLI itself surfaces and nothing else — a field-named `422` is
1374
+ // still the better error for everything inside the policy file.
1375
+ const policyGate = preflightSetupEpicPolicy({
1376
+ intoBase: opts.intoBase,
1377
+ policyFile: opts.policyFile,
1378
+ filePolicy,
1379
+ policyDeclaresBranch,
1380
+ featureBranch,
1381
+ reviewPolicy: opts.reviewPolicy,
1382
+ });
1383
+ if (!policyGate.ok) {
1384
+ deps.errorLog(policyGate.message);
1385
+ return 1;
1386
+ }
1138
1387
  // --- --local-only --dry-run: stop here, offline and explicitly partial ----
1139
1388
  // Nothing below this point is reachable without the network, and the whole
1140
1389
  // point of the flag is to not touch it — not even to resolve a credential.
1141
1390
  if (opts.localOnly) {
1142
- if (filePolicy !== undefined) {
1143
- const composed = composeSetupEpicPolicy(filePolicy, {
1144
- featureBranch: opts.featureBranch,
1145
- reviewPolicy: opts.reviewPolicy,
1146
- });
1147
- if (!composed.ok) {
1148
- deps.errorLog(composed.error);
1149
- return 1;
1150
- }
1151
- }
1391
+ // The policy gate above already composed and validated; nothing is repeated
1392
+ // here, so the offline path and the real path refuse the same set.
1152
1393
  say(`Epic: ${opts.epicKey}`);
1153
1394
  say(`Plan: v${plan.plan_version}, ${plan.nodes.length} node(s), ${plan.edges.length} edge(s)`);
1395
+ // BAPI-1009: the same node-count-derived strategy an ordinary run would take,
1396
+ // stated without cutting a branch, reading the network, or touching git.
1397
+ say(`Branch: ${branchStrategyLine}`);
1154
1398
  say(`Local hash: ${localHash}`);
1155
1399
  for (const w of warnings)
1156
1400
  say(` [warn] ${w}`);
@@ -1195,35 +1439,19 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1195
1439
  say(`Local hash: ${localHash}`);
1196
1440
  for (const w of warnings)
1197
1441
  say(` [warn] ${w}`);
1198
- // --- Feature branch selection (BAPI-655) --------------------------------
1199
- // Resolved AFTER local/access context is known but BEFORE any run-state read
1200
- // or mutating request, so malformed interactive input fails before network
1201
- // dispatch. Returns undefined to continue on the repository base branch.
1202
- const promptedInteractively = opts.featureBranch === undefined && !policyDeclaresBranch && deps.isTTY && !opts.json;
1203
- const featureBranch = await resolveFeatureBranchSelection(opts, access.repoName, deps, policyDeclaresBranch);
1204
- if (featureBranch !== undefined) {
1205
- say(`Feature: ${featureBranch} (cut on origin with local git at the canonical indexed ` +
1206
- `commit; validated server-side at approval)`);
1207
- }
1208
- else if (promptedInteractively) {
1209
- // Ordinary, non-warning notice — only when an interactive operator declined.
1210
- say("Feature: none continue using the repository base branch");
1211
- }
1212
- // --- Compose the requested run policy (BAPI-754) -------------------------
1213
- // A contradiction between the file and the flags is refused here, before the
1214
- // pre-check and long before any create, so no request is dispatched under a
1215
- // policy nobody chose.
1216
- let requestedPolicy;
1217
- if (filePolicy !== undefined) {
1218
- const composed = composeSetupEpicPolicy(filePolicy, {
1219
- featureBranch,
1220
- reviewPolicy: opts.reviewPolicy,
1221
- });
1222
- if (!composed.ok) {
1223
- deps.errorLog(composed.error);
1224
- return 1;
1225
- }
1226
- requestedPolicy = composed.policy;
1442
+ // --- Branch strategy (BAPI-1009) ----------------------------------------
1443
+ // Already resolved above, before the network was touched. Reported HERE, ahead
1444
+ // of any secondary policy detail, because it is the one decision that changes
1445
+ // where every child PR lands an operator who reads nothing else must still
1446
+ // see which topology this run is about to bootstrap.
1447
+ say(`Branch: ${branchStrategyLine}`);
1448
+ // --- The requested run policy (BAPI-754; gated by BAPI-1054) -------------
1449
+ // Composition and every contradiction refusal already happened in the policy
1450
+ // preflight above, before credentials were resolved. This is the SAME object
1451
+ // that passed the gate — not a second composition — so the payload the create
1452
+ // request carries is exactly what was validated.
1453
+ const requestedPolicy = policyGate.policy;
1454
+ if (requestedPolicy !== undefined) {
1227
1455
  say(`Policy: ${opts.policyFile} (complete run policy, applied at creation)`);
1228
1456
  }
1229
1457
  // --- Effective feature branch (BAPI-850) ----------------------------------
@@ -1291,16 +1519,55 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1291
1519
  // --- Feature-branch conflict guard against an existing live run ----------
1292
1520
  // A selected branch that disagrees with the existing run's stored base branch
1293
1521
  // must fail closed rather than patch or silently change that run. A matching
1294
- // branch is allowed (the rerun re-validates remote provisioning at approval);
1295
- // supplying no branch preserves the existing run's policy untouched.
1522
+ // branch is allowed (the rerun re-validates remote provisioning at approval).
1523
+ //
1524
+ // BAPI-1009: `featureBranch` can now be DERIVED, so this guard also catches the
1525
+ // new case — a live run created under the old direct-to-base default, re-run
1526
+ // after the default flipped. Continuing would dispatch children at base while
1527
+ // every message on screen named an epic branch. The remedy differs by how the
1528
+ // branch was chosen, so the guidance names the flag that actually applies:
1529
+ // dropping `--feature-branch` reuses the run only when the flag is what asked
1530
+ // for the branch; when the DEFAULT asked, `--into-base` is the way to say
1531
+ // "keep this run on base".
1296
1532
  if (existingRunId && featureBranch !== undefined && existingBaseBranch !== featureBranch) {
1533
+ const reuseAdvice = opts.featureBranch !== undefined
1534
+ ? "Re-run without --feature-branch to reuse it unchanged"
1535
+ : `Re-run with --into-base to reuse it on the base branch it was created with`;
1297
1536
  deps.errorLog(`Epic ${opts.epicKey} already has a live run (${existingRunId}) whose feature ` +
1298
1537
  `branch is ${existingBaseBranch ? `'${existingBaseBranch}'` : "unset (base branch)"}, ` +
1299
- `which conflicts with the requested '${featureBranch}'. setup-epic will not ` +
1300
- `retarget or rebuild an existing run. Re-run without --feature-branch to reuse ` +
1301
- `it unchanged, or abandon the run to start over on a new branch.`);
1538
+ `which conflicts with the selected '${featureBranch}'. setup-epic will not ` +
1539
+ `retarget or rebuild an existing run. ${reuseAdvice}, or abandon the run to ` +
1540
+ `start over on a new branch.`);
1302
1541
  return 1;
1303
1542
  }
1543
+ // BAPI-1009: the mirror image — `--into-base` against a run that already lives
1544
+ // on an epic branch. The guard above cannot see this one, because `--into-base`
1545
+ // resolves to NO selected branch and so never disagrees with a stored value.
1546
+ // Reusing silently would report base mode for a run whose scope, cut, and
1547
+ // integration PR all belong to the epic branch.
1548
+ //
1549
+ // A stored `base_branch` is NOT by itself an epic branch: a run created with
1550
+ // `base_branch: "main"` is ALREADY on base, and refusing it would be a false
1551
+ // refusal. So the comparison is made against the repository's configured base,
1552
+ // resolved exactly the way the cut path below resolves it — same client, same
1553
+ // `"main"` fallback for an unreadable config — because "a branch equal to the
1554
+ // configured base is not scope-bearing" is an existing rule here, and a second
1555
+ // rule that disagreed with it would be worse than the extra read.
1556
+ if (existingRunId && opts.intoBase && existingBaseBranch !== null) {
1557
+ const configuredBase = await getConfigFieldBaseBranch(access, deps.fetch);
1558
+ const repositoryBase = configuredBase.ok && configuredBase.value.base_branch !== null
1559
+ ? configuredBase.value.base_branch
1560
+ : "main";
1561
+ if (existingBaseBranch !== repositoryBase) {
1562
+ deps.errorLog(`Epic ${opts.epicKey} already has a live run (${existingRunId}) on feature branch ` +
1563
+ `'${existingBaseBranch}', not the repository base '${repositoryBase}', and ` +
1564
+ `--into-base asks for the repository base branch. setup-epic will not retarget ` +
1565
+ `an existing run. Re-run without --into-base to reuse it unchanged, or abandon ` +
1566
+ `the run (conductor abandon-run --epic-run-id ${existingRunId}) and recreate ` +
1567
+ `it on base.`);
1568
+ return 1;
1569
+ }
1570
+ }
1304
1571
  // --- Review-policy conflict guard against an existing live run ----------
1305
1572
  // BAPI-679, analogous to the feature-branch guard above. An explicitly selected
1306
1573
  // policy that disagrees with a live run's effective policy must fail closed:
@@ -1325,12 +1592,55 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1325
1592
  // row and an automation-start debit on the way to finding that out.
1326
1593
  let validated = null;
1327
1594
  let validateLaneMissing = false;
1595
+ // BAPI-1027 — the evidence the successful validation was given, carried onto
1596
+ // the later store call so both lanes judge the plan against the same facts.
1597
+ let trackedPathEvidence;
1328
1598
  try {
1329
1599
  validated = await validateEpicPlan(access, {
1330
1600
  planVersion: plan.plan_version,
1331
1601
  planBlob: plan,
1332
1602
  epicKey: opts.epicKey,
1333
1603
  }, deps.fetch);
1604
+ // BAPI-1027 — phase two of the two-phase exchange. The server names the
1605
+ // cited-document paths it could not resolve; only THEN does the CLI touch
1606
+ // git, and only for those exact paths. The rules stay server-side: the CLI
1607
+ // supplies facts and never decides whether a plan passes.
1608
+ //
1609
+ // Skipped entirely when the first response already carries blocking
1610
+ // rejections. Those are unrelated to trackedness, the plan is going to be
1611
+ // refused either way, and running git plus a second billable-free round trip
1612
+ // to enrich a verdict that will not change is pure latency.
1613
+ const unresolved = unresolvedCitedDocPaths(validated);
1614
+ if (validated.rejections.length === 0 && unresolved.length > 0) {
1615
+ const oracleRun = deps.runCommand;
1616
+ if (!oracleRun) {
1617
+ deps.errorLog(`Validation could not be completed: ${unresolved.length} cited document ` +
1618
+ `path(s) need a git trackedness check, but no local command runner is ` +
1619
+ `available in this environment. No run was created and no ` +
1620
+ `automation-start charge occurred.`);
1621
+ return 1;
1622
+ }
1623
+ const resolution = await resolveTrackedPaths(deps.cwd, unresolved, {
1624
+ runCommand: oracleRun,
1625
+ });
1626
+ if (!resolution.ok) {
1627
+ // An unresolved oracle is "validation could not be completed", NEVER
1628
+ // "these documents are missing". Continuing would store a plan whose
1629
+ // citations were never checked while reporting a clean preflight.
1630
+ deps.errorLog(`Validation could not be completed: the cited documents in this plan ` +
1631
+ `could not be checked against version control.\n${resolution.error}\n` +
1632
+ `No run was created and no automation-start charge occurred. Re-run ` +
1633
+ `from inside the repository working tree once git is available.`);
1634
+ return 1;
1635
+ }
1636
+ trackedPathEvidence = resolution.facts;
1637
+ validated = await validateEpicPlan(access, {
1638
+ planVersion: plan.plan_version,
1639
+ planBlob: plan,
1640
+ epicKey: opts.epicKey,
1641
+ trackedPaths: trackedPathEvidence,
1642
+ }, deps.fetch);
1643
+ }
1334
1644
  }
1335
1645
  catch (err) {
1336
1646
  const status = err instanceof ConductorBridgeApiError ? err.status : undefined;
@@ -1353,16 +1663,36 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1353
1663
  `occurred.\nDetail: ${errorDetail(err)}`);
1354
1664
  return 1;
1355
1665
  }
1666
+ else if (isRetryableConflict(err)) {
1667
+ // BAPI-1027 — a RETRYABLE stale-CAS conflict (post-BAPI-1016). The plan is
1668
+ // fine and so is the epic; someone else wrote concurrently between the read
1669
+ // and this call. Rendered with retry language and deliberately WITHOUT any
1670
+ // close/adopt/abandon wording: telling an operator to abandon a run over a
1671
+ // conflict they should simply retry is the exact confusion the distinct
1672
+ // codes exist to prevent.
1673
+ deps.errorLog(`Plan changed concurrently; re-read and retry validation.\n` +
1674
+ `Nothing was created, stored, or charged — this is a transient ` +
1675
+ `concurrency conflict, not a problem with your plan or your epic.\n` +
1676
+ `Detail: ${errorDetail(err)}`);
1677
+ return 1;
1678
+ }
1356
1679
  else if (status === 409) {
1357
1680
  // The epic is WEDGED (multiple active runs), which says nothing about the
1358
1681
  // plan. Reporting this as "server validation: failed" would send the
1359
1682
  // operator to fix a sidecar that is perfectly fine. The pre-check above
1360
1683
  // normally catches this first; this branch covers the race where a
1361
1684
  // duplicate run appears between the pre-check and the validate call.
1685
+ //
1686
+ // BAPI-1027 — TERMINAL, and said so plainly: the server now names this
1687
+ // `ACTIVE_EPIC_RUN_EXISTS`, distinct from the retryable conflict handled
1688
+ // above. Retrying never clears it. The branch still fires on a bare 409
1689
+ // from a server that predates the code, so an older deployment keeps the
1690
+ // behavior it always had.
1362
1691
  deps.errorLog(`Epic ${opts.epicKey} has MULTIPLE active runs — the plan could not be ` +
1363
1692
  `validated because the epic itself is wedged, and every plan call will ` +
1364
- `keep failing. This is NOT a problem with your plan. Abandon the ` +
1365
- `duplicate before retrying:\n` +
1693
+ `keep failing. This is TERMINAL: it will not clear on retry, and it is ` +
1694
+ `NOT a problem with your plan. Adopt the run you want to keep, or ` +
1695
+ `abandon the duplicate:\n` +
1366
1696
  ` conductor abandon-run --epic-run-id <duplicate_epic_run_id>\n` +
1367
1697
  `No run was created and no automation-start charge occurred.\n` +
1368
1698
  `Detail: ${errorDetail(err)}`);
@@ -1374,9 +1704,37 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1374
1704
  return 1;
1375
1705
  }
1376
1706
  }
1707
+ if (validated && validated.rejections.length > 0) {
1708
+ // BAPI-1027 — a lint rejection is the plan's own fault and is terminal here:
1709
+ // no run is created, nothing is stored, and no charge occurs. Rendered to
1710
+ // STDERR (never stdout, which under --json carries the single result object
1711
+ // and which in MCP server mode is the stdio transport), one compact line per
1712
+ // rejection so an operator can fix every fault in one pass instead of
1713
+ // rediscovering them one round trip at a time.
1714
+ deps.errorLog(`Plan needs attention: ${validated.rejections.length} lint rejection(s).`);
1715
+ for (const rejection of validated.rejections) {
1716
+ deps.errorLog(` ${rejection.reasonCode}${renderRejectionSubject(rejection)}: ${rejection.explanation}`);
1717
+ }
1718
+ deps.errorLog("The plan was NOT stored and no run was created; no automation-start charge " +
1719
+ "occurred. Fix the plan file and re-run — setup-epic never rewrites a plan, " +
1720
+ "because that would change its hash.");
1721
+ return 1;
1722
+ }
1377
1723
  if (validated) {
1378
1724
  say("Validation: server validation: passed");
1725
+ say(" Plan ready to store");
1726
+ say(" No lint rejections found");
1379
1727
  say(` plan hash ${validated.planHash}`);
1728
+ // BAPI-1027 — "nothing was found" and "everything was checked" are different
1729
+ // statements, and reporting the first as the second is the false assurance
1730
+ // this lane exists to remove. An incomplete evaluation is a WARNING on
1731
+ // stderr: the plan is storable, and the operator is told what went unchecked.
1732
+ if (!validated.evaluationComplete) {
1733
+ for (const notice of validated.notices) {
1734
+ deps.errorLog(`WARNING: ${notice.noticeCode}: ${notice.explanation}` +
1735
+ (notice.paths.length > 0 ? `\n Unchecked: ${notice.paths.join(", ")}` : ""));
1736
+ }
1737
+ }
1380
1738
  // BAPI-848 — three distinct outcomes, never collapsed into success language.
1381
1739
  // "0 edges inserted" used to read as proof the siblings could not conflict.
1382
1740
  // It never was: serialization only sees paths BOTH nodes declared, and the
@@ -1622,7 +1980,17 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1622
1980
  else {
1623
1981
  // The pre-check said there was no live run, but create found one — a
1624
1982
  // concurrent setup won the race. Treat it exactly like the reuse path.
1625
- say(`Run: reusing ${run.epic_run_id} (status: ${run.status})`);
1983
+ //
1984
+ // BAPI-1027 — this ADOPTION is the normal answer to "a second setup for
1985
+ // one epic", not an error: `create_epic_run` returns the existing
1986
+ // non-terminal run untouched, and the `uq_epic_runs_active_repo_epic_key`
1987
+ // index is the race backstop that keeps it so. Naming the adopted run and
1988
+ // its status, rather than reporting a bare reuse, is what lets an operator
1989
+ // decide between continuing on it and closing it.
1990
+ say(`Run: adopting existing active run ${run.epic_run_id} (status: ${run.status})`);
1991
+ say(` No second run was created — one epic has at most one active ` +
1992
+ `run. Continue on this one, or abandon it first with ` +
1993
+ `\`conductor abandon-run --epic-run-id ${run.epic_run_id}\` to start over.`);
1626
1994
  if (requestedPolicy !== undefined) {
1627
1995
  const outcome = await applyReusedRunPolicy({
1628
1996
  access,
@@ -1642,6 +2010,18 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1642
2010
  catch (err) {
1643
2011
  const status = err instanceof ConductorBridgeApiError ? err.status : undefined;
1644
2012
  if (status === 422 && requestedPolicy !== undefined) {
2013
+ // BAPI-1054 investigated whether `errorDetail`'s `bodyPreview` could echo
2014
+ // policy content here — a run policy can carry `notify.webhook_url`, and
2015
+ // a FastAPI validation detail routinely repeats the submitted `input`.
2016
+ //
2017
+ // It cannot, and the guard is one layer down where it belongs:
2018
+ // `formatValidationDetailItem` (conductor/bridge-api-client.ts) reduces a
2019
+ // 422 detail to `<loc path>: <msg>` — field NAMES and the validator's own
2020
+ // message, with `input` never read and the transport-only `body` prefix
2021
+ // dropped — and `redactDiagnosticValues` then scrubs known secrets.
2022
+ // Re-sanitizing here would only destroy the field name that makes the
2023
+ // server's field-named 422 actionable. The property is pinned by the
2024
+ // regression test below rather than left to this comment.
1645
2025
  deps.errorLog(`The run policy from '${opts.policyFile}' was rejected by the server.\n` +
1646
2026
  `${errorDetail(err)}\n` +
1647
2027
  `No run was created and no automation-start charge occurred. Fix the ` +
@@ -1685,7 +2065,22 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1685
2065
  // and replace a stale record on its own, and there is nothing running to
1686
2066
  // hand this binding to yet.
1687
2067
  }
1688
- // A missing or unvalidated manifest is normal — no attempt, no warning.
2068
+ else if (manifestRead.kind !== "missing") {
2069
+ // NOT normal, and previously silent (BAPI-1029). `missing` genuinely
2070
+ // means "no local plane" — remote runs, CI, any non-plane workflow — and
2071
+ // stays quiet. `malformed` and `unreadable` mean a manifest EXISTS and
2072
+ // this build could not validate it, which is exactly what happens when
2073
+ // it was written by a newer build. A plane may well be running, and the
2074
+ // binding it needed just did not happen; saying nothing left the
2075
+ // operator to discover that at `plane down` time.
2076
+ const msg = `A local plane manifest exists but could not be validated (${manifestRead.error}), so ` +
2077
+ `run ${result.epic_run_id} was NOT bound to it. If a plane is running, \`plane down\` ` +
2078
+ "will not stop this run automatically — use " +
2079
+ `\`conductor stop-run --epic-run-id ${result.epic_run_id}\`. A manifest written by a ` +
2080
+ "NEWER build must be wound down with that build's `plane down`.";
2081
+ warnings.push(msg);
2082
+ say(`Plane: [warn] ${msg}`);
2083
+ }
1689
2084
  }
1690
2085
  catch (err) {
1691
2086
  // The binding capability itself must never fail setup-epic as a whole;
@@ -1764,6 +2159,12 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1764
2159
  planVersion: plan.plan_version,
1765
2160
  planBlob: plan,
1766
2161
  planHash: localHash,
2162
+ // BAPI-1027 — the same working-tree facts the dry run was validated
2163
+ // against. Without them the store lane would re-evaluate this plan with
2164
+ // NO evidence, report cited-document reachability as "not evaluated",
2165
+ // and quietly reach a different verdict than the preflight the operator
2166
+ // was just shown.
2167
+ ...(trackedPathEvidence !== undefined ? { trackedPaths: trackedPathEvidence } : {}),
1767
2168
  }, deps.fetch);
1768
2169
  result.plan_stored = true;
1769
2170
  const serverHash = stored?.plan_hash;
@@ -1870,8 +2271,14 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1870
2271
  exitCode = 1;
1871
2272
  }
1872
2273
  else {
1873
- say(`Scope: waiting for index scope readiness (bounded)…`);
1874
- const verdict = await pollIndexScopeLifecycle(cutProtocolDeps(deps, cutRunCommand ?? deps.runCommand ?? createExecFileRunCommand()), access, scopeId, { onTransition: (state, status) => say(describeScopeTransition(state, status)) });
2274
+ say(`Scope: waiting up to ~${SETUP_EPIC_SCOPE_DEFERRED_WINDOW_SECONDS}s for index scope ` +
2275
+ `readiness; if it is still preparing, this returns and preparation continues server-side.`);
2276
+ const verdict = await pollIndexScopeLifecycle(cutProtocolDeps(deps, cutRunCommand ?? deps.runCommand ?? createExecFileRunCommand()), access, scopeId, {
2277
+ onTransition: (state, status) => say(describeScopeTransition(state, status)),
2278
+ // BAPI-1054: THIS caller's bound only. The shared default is untouched,
2279
+ // so nothing else that drives this poll waits any less than it did.
2280
+ maxPolls: SETUP_EPIC_SCOPE_DEFERRED_MAX_POLLS,
2281
+ });
1875
2282
  if (verdict.kind === "ready") {
1876
2283
  result.index_scope = {
1877
2284
  scope_id: scopeId,
@@ -1916,20 +2323,25 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1916
2323
  exitCode = 1;
1917
2324
  }
1918
2325
  else {
2326
+ // BAPI-1054 (AC-2): NOT a failure. The run is created and approved, and
2327
+ // the reconciler holds dispatch until the scope is ready — a safe hold.
2328
+ // Reporting it as exit 1 after a ~20-minute block made a perfectly good
2329
+ // run read as a failed command, which is the whole defect this closes.
2330
+ //
2331
+ // Every rendered value is bounded: the run id, the scope id, and a
2332
+ // lifecycle label the poll already collapsed to a known label,
2333
+ // `unreadable`, or `unknown`. No raw status, no server error text, no URL.
2334
+ const lastState = boundScopeLifecycleLabel(verdict.lastState);
1919
2335
  result.index_scope = {
1920
2336
  scope_id: scopeId,
1921
2337
  cut_commit_sha: verdict.lastStatus?.cut_commit_sha ?? cutCommitSha,
1922
- lifecycle_state: verdict.lastStatus?.lifecycle_state ?? null,
2338
+ lifecycle_state: lastState,
1923
2339
  ready: false,
1924
- disposition: "timeout",
2340
+ disposition: "created_cut_deferred",
1925
2341
  };
1926
- deps.errorLog(`Index scope ${scopeId} did not become ready within the bounded wait ` +
1927
- `(last observed state: ${verdict.lastState}). The run ${result.epic_run_id} is ` +
1928
- `created and scope preparation continues asynchronously on the server; the ` +
1929
- `reconciler will NOT dispatch tickets until the scope becomes ready. Check ` +
1930
- `GET /jira/index-scope/status?repo_name=${access.repoName}&scope_id=${scopeId}, ` +
1931
- `or re-run setup-epic to wait again.`);
1932
- exitCode = 1;
2342
+ say(`Scope: ${SETUP_EPIC_CUT_DEFERRED_PHRASE} run ${result.epic_run_id}, ` +
2343
+ `scope ${scopeId}, last observed state '${lastState}'. Scope preparation ` +
2344
+ `continues server-side; the reconciler holds dispatch until it is ready.`);
1933
2345
  }
1934
2346
  }
1935
2347
  }
@@ -1947,7 +2359,16 @@ export async function runSetupEpicCli(argv, overrides = {}) {
1947
2359
  "order was used. Check that BAPI_BASE_URL points at the intended deployment.");
1948
2360
  }
1949
2361
  say(`Epic run ${result.epic_run_id} is ${result.status ?? "unknown"}.`);
1950
- say("The server-side reconciler will pick it up within ~30s.");
2362
+ // BAPI-1054: a deferred cut means the reconciler picks the run up but HOLDS
2363
+ // dispatch, so the unqualified "~30s" line would contradict the
2364
+ // acknowledgement printed moments earlier.
2365
+ if (result.index_scope?.disposition === "created_cut_deferred") {
2366
+ say("The server-side reconciler will pick it up within ~30s, but holds ticket " +
2367
+ "dispatch until the index scope finishes preparing.");
2368
+ }
2369
+ else {
2370
+ say("The server-side reconciler will pick it up within ~30s.");
2371
+ }
1951
2372
  say("To execute claimed jobs on this machine, run:");
1952
2373
  say(` npx -y ${MCP_PACKAGE_NAME} executor --repo ${access.repoName}`);
1953
2374
  }