@bridge_gpt/mcp-server 0.2.39 → 0.2.42

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 (74) hide show
  1. package/README.md +10 -10
  2. package/build/agent-capabilities/cli.js +2 -1
  3. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  4. package/build/claude-user-config-doctor.js +42 -11
  5. package/build/cli-release.js +2 -1
  6. package/build/commands.generated.js +4 -4
  7. package/build/conduct-epic/bridge-client.js +354 -113
  8. package/build/conduct-epic/checkpoint-store.js +75 -2
  9. package/build/conduct-epic/cli.js +795 -109
  10. package/build/conduct-epic/cut-protocol.js +327 -0
  11. package/build/conduct-epic/pr-state.js +113 -24
  12. package/build/conduct-epic/spawn.js +14 -2
  13. package/build/conductor/bridge-api-client.js +27 -1
  14. package/build/conductor/cli.js +46 -1
  15. package/build/conductor/doctor.js +101 -16
  16. package/build/conductor/epic-reconcile.js +72 -19
  17. package/build/conductor/epic-runtime.js +15 -3
  18. package/build/conductor/errors.js +47 -0
  19. package/build/conductor/git-hooks.js +205 -11
  20. package/build/conductor/install-doctor.js +230 -1
  21. package/build/conductor/local-merge.js +130 -28
  22. package/build/conductor/tools.js +32 -3
  23. package/build/conductor/worker-ledger-cli.js +27 -1
  24. package/build/conductor-bin.js +15 -15
  25. package/build/credentials-cli.js +3 -2
  26. package/build/doctor.js +107 -41
  27. package/build/executor/cli.js +48 -1
  28. package/build/executor/env.js +21 -0
  29. package/build/executor/index-scope.js +39 -0
  30. package/build/executor/job-log-registry.js +69 -0
  31. package/build/executor/job-runner.js +148 -26
  32. package/build/executor/live-worker-registry.js +83 -0
  33. package/build/executor/observation.js +167 -6
  34. package/build/executor/platform.js +147 -3
  35. package/build/executor/process.js +58 -14
  36. package/build/executor/runner.js +235 -48
  37. package/build/executor/test-clock.js +3 -2
  38. package/build/index-scope-contract.js +96 -0
  39. package/build/index.js +153 -204
  40. package/build/init.js +83 -22
  41. package/build/install-bridge-conductor.js +323 -14
  42. package/build/install-bridge.js +202 -38
  43. package/build/install-doctor.js +23 -9
  44. package/build/install-reexec.js +2 -1
  45. package/build/launcher-config-inspection.js +83 -22
  46. package/build/mcp-host-config.js +331 -67
  47. package/build/mcp-host-targets.js +45 -21
  48. package/build/mcp-identity.js +92 -0
  49. package/build/mcp-install-state.js +94 -1
  50. package/build/mcp-invoke.js +2 -1
  51. package/build/mcp-provisioning.js +45 -12
  52. package/build/mcp-registration-doctor.js +35 -13
  53. package/build/mcp-server-invocation.js +4 -2
  54. package/build/merge-pull-request.js +208 -9
  55. package/build/pipelines.generated.js +3 -3
  56. package/build/plane/defaults.js +4 -1
  57. package/build/plane/preflight.js +81 -10
  58. package/build/plane/test-fakes.js +9 -1
  59. package/build/readme.generated.js +1 -1
  60. package/build/regression-check.js +3 -2
  61. package/build/review-tickets.js +8 -7
  62. package/build/run-unit-tests-launcher.js +74 -1
  63. package/build/schedule-run.js +3 -2
  64. package/build/setup-epic.js +453 -78
  65. package/build/sfcc/tool-wrapper.js +15 -0
  66. package/build/start-tickets-prereqs.js +11 -6
  67. package/build/start-tickets.js +91 -85
  68. package/build/update-check.js +3 -2
  69. package/build/upgrade-advice.js +2 -1
  70. package/build/upgrade-cli.js +50 -18
  71. package/build/version.generated.js +1 -1
  72. package/docs/CONDUCTOR.md +22 -0
  73. package/docs/install/mcp-tool-integrations.md +19 -3
  74. package/package.json +2 -2
@@ -29,7 +29,6 @@
29
29
  * - **Credentials resolve only through `resolveConductorBridgeApiAccess`** and
30
30
  * never enter argv, stdout, stderr, a journal line, or an error string.
31
31
  */
32
- import { execFile } from "node:child_process";
33
32
  import { promises as nodeFs } from "node:fs";
34
33
  import os from "node:os";
35
34
  import path from "node:path";
@@ -40,21 +39,41 @@ import { runGhCommand } from "../conductor/pr-discovery.js";
40
39
  import { getDefaultSpawnTerminalTabForPlatform, detectTerminal, createDefaultStartTicketsDeps, } from "../start-tickets.js";
41
40
  import { resolveWorktrunkBinary } from "../start-tickets-prereqs.js";
42
41
  import { resolveRequiredStartTicketsRepoName } from "../start-tickets-repo.js";
43
- import { getConfigFieldBaseBranch, getConductorReadiness, getEffectiveSupervisorConfig, getEffectiveSupervisorSetup, getEpicRunState, getIndexBranch, getParseStatus, getPrReviewStatus, pollCiChecks, putSupervisorConfigDefaults, repointIndexBranch, resolveCiChecks, restoreIndexBranch, } from "./bridge-client.js";
42
+ import { bootstrapIndexScope, getConfigFieldBaseBranch, getConductorReadiness, getIndexScopeLifecycle, getIndexScopeStatus, getEffectiveSupervisorConfig, getEffectiveSupervisorSetup, getEpicRunState, getParseStatus, getPrReviewStatus, heartbeatIndexScope, pollCiChecks, putSupervisorConfigDefaults, reclaimIndexScope, recoverIndexScope, resolveCiChecks, retireIndexScope, } from "./bridge-client.js";
44
43
  import { appendTicketJournal, createInitialConductEpicCheckpoint, readConductEpicCheckpoint, resolveConductEpicCheckpointPath, resolveConductEpicLockPath, writeConductEpicCheckpointAtomic, CONDUCT_EPIC_TICKET_STATUSES, } from "./checkpoint-store.js";
45
44
  import { acquireConductEpicLock, inspectConductEpicLock, isConductEpicLockOwnerAlive, } from "./lock.js";
46
45
  import { discoverConductEpicPrState, discoverTicketWorktree, parseGitWorktreePorcelain, } from "./pr-state.js";
47
46
  import { spawnConductEpicAgentTab, CONDUCT_EPIC_AGENTS, } from "./spawn.js";
47
+ import { MCP_PACKAGE_NAME } from "../mcp-identity.js";
48
+ import { INDEX_SCOPE_CONFIGURATION_ERROR, validateOptionalIndexScope, } from "../index-scope-contract.js";
49
+ // BAPI-850: the exact-cut protocol, the scope-readiness poll bounds, and the
50
+ // local-git helpers live in ONE shared module that `setup-epic` drives too. This
51
+ // file remains the pilot's owner of the preflight and of how a cut outcome is
52
+ // reported; the cut itself is performed by the shared module.
53
+ import { createExecFileRunCommand, firstOutputLine as firstLine, lsRemoteSha, normalizeCommitSha, performExactIndexScopeCut, runGit, SCOPE_BOOTSTRAP_MAX_POLLS, SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, } from "./cut-protocol.js";
54
+ // Re-exported so existing importers of the pilot's normalizer keep compiling.
55
+ export { normalizeCommitSha };
48
56
  /** Epic and ticket keys accepted by every verb. */
49
57
  export const CONDUCT_EPIC_KEY_PATTERN = /^[A-Z]+-[0-9]+$/;
50
58
  /** The five verb families. `checkpoint set` is two tokens, one verb. */
51
- export const CONDUCT_EPIC_VERBS = ["init", "status", "checkpoint set", "finish", "spawn"];
59
+ export const CONDUCT_EPIC_VERBS = [
60
+ "init",
61
+ "status",
62
+ "checkpoint set",
63
+ "finish",
64
+ "spawn",
65
+ "recover",
66
+ "retire",
67
+ "reclaim",
68
+ ];
52
69
  /** Per-ticket fields `checkpoint set` may assign. */
53
70
  const TICKET_FIELDS = [
54
71
  "status",
55
72
  "branch",
56
73
  "pr_number",
57
74
  "spawned_at",
75
+ "parse_requested_at",
76
+ "parse_requested_for_sha",
58
77
  "respawns",
59
78
  "conflict_attempts",
60
79
  "counters.sessions_spawned",
@@ -65,24 +84,7 @@ const TICKET_FIELDS = [
65
84
  const TOP_LEVEL_FIELDS = ["needs_human", "counters.iterations", "counters.merges"];
66
85
  /** Build the production dependency set. */
67
86
  export function createDefaultConductEpicDeps() {
68
- const runCommand = (file, args, options) => new Promise((resolve) => {
69
- execFile(file, args, {
70
- cwd: options?.cwd,
71
- // Git porcelain output for a many-worktree checkout can be large.
72
- maxBuffer: 16 * 1024 * 1024,
73
- encoding: "utf-8",
74
- timeout: options?.timeoutMs,
75
- // Explicit: arguments are a list, never a concatenated shell string.
76
- shell: false,
77
- }, (error, stdout, stderr) => {
78
- const code = error?.code;
79
- resolve({
80
- stdout: stdout ?? "",
81
- stderr: stderr ?? "",
82
- exitCode: typeof code === "number" ? code : error ? 1 : 0,
83
- });
84
- });
85
- });
87
+ const runCommand = createExecFileRunCommand();
86
88
  const spawner = getDefaultSpawnTerminalTabForPlatform(process.platform);
87
89
  const startTicketsDeps = createDefaultStartTicketsDeps();
88
90
  return {
@@ -119,6 +121,7 @@ export function createDefaultConductEpicDeps() {
119
121
  cwd: process.cwd(),
120
122
  pid: process.pid,
121
123
  isProcessAlive: isConductEpicLockOwnerAlive,
124
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
122
125
  log: (m) => console.log(m),
123
126
  errorLog: (m) => console.error(m),
124
127
  resolveAccess: resolveConductorBridgeApiAccess,
@@ -132,12 +135,16 @@ export function createDefaultConductEpicDeps() {
132
135
  export function getConductEpicUsage() {
133
136
  return [
134
137
  "Usage:",
135
- " npx -y @bridge_gpt/mcp-server conduct-epic <verb> [flags]",
138
+ ` npx -y ${MCP_PACKAGE_NAME} conduct-epic <verb> [flags]`,
136
139
  "",
137
140
  "Verbs:",
138
141
  " init <EPIC> --tickets K1,K2,... [--base-branch <b>] [--checkpoint-path <p>] [--dry-run] [--json]",
139
- " Run the full preflight, create epic/<EPIC> on origin at the fetched base",
140
- " tip, repoint the indexed branch, write the checkpoint, and take the lock.",
142
+ " Run the full preflight, then create epic/<EPIC> on origin at the commit the",
143
+ " CANONICAL INDEX covers — not the base tip seed and verify the epic's index",
144
+ " scope at that commit, repoint the indexed branch, write the checkpoint, and",
145
+ " take the lock. --base-branch selects the base whose history is fetched and",
146
+ " recorded; the cut commit is the canonical indexed SHA and is reported",
147
+ " separately. init fails closed when the repository has no successful parse.",
141
148
  " Every preflight failure is printed in one pass; nothing is written unless",
142
149
  " all of them pass. --dry-run prints the validated plan and writes nothing.",
143
150
  "",
@@ -146,12 +153,17 @@ export function getConductEpicUsage() {
146
153
  " CI, review, parse, deadline, and lock state. --json is required. A missing",
147
154
  " checkpoint exits 0 with checkpoint_exists:false. A failed probe leaves its",
148
155
  " sub-object null and is listed in probe_errors; it never fails the command.",
156
+ " `scopes` lists EVERY index scope this repository owns — expired and",
157
+ " reclaiming ones included — so a crashed epic is visible without SQL.",
149
158
  "",
150
159
  " checkpoint set <EPIC> --ticket <KEY> [--field <name> <value>]... [--journal <line>]",
151
160
  " [--checkpoint-path <p>]",
152
161
  " Apply ABSOLUTE field values (the caller computes n+1 from status).",
153
162
  ` Ticket fields: ${TICKET_FIELDS.join(", ")}.`,
154
163
  ` Top-level fields: ${TOP_LEVEL_FIELDS.join(", ")}.`,
164
+ " Repeat --field to write several in ONE atomic mutation.",
165
+ " parse_requested_at / parse_requested_for_sha are ACCEPTED for older",
166
+ " checkpoints but no longer written: freshness is read from the scope.",
155
167
  "",
156
168
  " finish <EPIC> [--checkpoint-path <p>] [--json]",
157
169
  " Restore the server's indexed base branch (idempotent), release the owned",
@@ -163,6 +175,26 @@ export function getConductEpicUsage() {
163
175
  " file's contents, then increment counters.sessions_spawned and append a",
164
176
  " journal line. Respawn and conflict budgets are the CALLER's job.",
165
177
  "",
178
+ " recover <EPIC> [--scope <id>] [--checkpoint-path <p>] [--json]",
179
+ " Take a NEW ownership generation for a crashed epic's index scope and",
180
+ " record the returned fencing epoch locally. Use this instead of SQL when",
181
+ " `status` shows a scope whose lease expired. Defaults to the epic's own",
182
+ " scope; --scope targets another one (e.g. when the checkpoint is gone).",
183
+ "",
184
+ " retire <EPIC> [--scope <id>] [--checkpoint-path <p>] [--json]",
185
+ " Start the scope's retention clock. Deletes NOTHING — the scope stays",
186
+ " readable for post-mortem for the whole retention window. Idempotent.",
187
+ " `finish` does this for you; this verb is for retiring without finishing.",
188
+ "",
189
+ " reclaim <EPIC> [--scope <id>] [--override-retention] [--checkpoint-path <p>] [--json]",
190
+ " Ask the server to schedule the scope's teardown: three Pinecone",
191
+ " namespaces, six parse-table slices, three config rows, and a retained",
192
+ " tombstone. Returns as soon as it is SCHEDULED; watch `status` for the",
193
+ " result. --override-retention waives only the still-valid-lease and",
194
+ " unelapsed-retention waits — an active parse, a held parse lock, a live",
195
+ " automation run, or a live epic run still refuse. There is no raw",
196
+ " deletion mode.",
197
+ "",
166
198
  "Common:",
167
199
  " -h, --help Show this help",
168
200
  "",
@@ -185,6 +217,9 @@ const VERB_FLAGS = {
185
217
  "checkpoint-set": ["--ticket", "--field", "--journal", "--checkpoint-path"],
186
218
  finish: ["--checkpoint-path", "--json"],
187
219
  spawn: ["--ticket", "--prompt-file", "--agent", "--checkpoint-path", "--json"],
220
+ recover: ["--scope", "--checkpoint-path", "--json"],
221
+ retire: ["--scope", "--checkpoint-path", "--json"],
222
+ reclaim: ["--scope", "--override-retention", "--checkpoint-path", "--json"],
188
223
  };
189
224
  /**
190
225
  * Parse and fully validate argv BEFORE any I/O.
@@ -210,7 +245,13 @@ export function parseConductEpicArgs(argv) {
210
245
  verb = "checkpoint-set";
211
246
  rest = argv.slice(2);
212
247
  }
213
- else if (argv[0] === "init" || argv[0] === "status" || argv[0] === "finish" || argv[0] === "spawn") {
248
+ else if (argv[0] === "init" ||
249
+ argv[0] === "status" ||
250
+ argv[0] === "finish" ||
251
+ argv[0] === "spawn" ||
252
+ argv[0] === "recover" ||
253
+ argv[0] === "retire" ||
254
+ argv[0] === "reclaim") {
214
255
  verb = argv[0];
215
256
  rest = argv.slice(1);
216
257
  }
@@ -225,6 +266,7 @@ export function parseConductEpicArgs(argv) {
225
266
  fields: [],
226
267
  dryRun: false,
227
268
  json: false,
269
+ overrideRetention: false,
228
270
  };
229
271
  const seen = new Set();
230
272
  let epicKey;
@@ -253,6 +295,9 @@ export function parseConductEpicArgs(argv) {
253
295
  case "--json":
254
296
  options.json = true;
255
297
  break;
298
+ case "--override-retention":
299
+ options.overrideRetention = true;
300
+ break;
256
301
  case "--field": {
257
302
  const name = rest[i + 1];
258
303
  const value = rest[i + 2];
@@ -342,6 +387,16 @@ function assignFlagValue(options, flag, value) {
342
387
  case "--journal":
343
388
  options.journal = value;
344
389
  return null;
390
+ case "--scope": {
391
+ // Shape-validated here, before any I/O: a server-minted scope id is a uuid4
392
+ // hex. Refusing a malformed value at the boundary means a typo never becomes
393
+ // an authenticated request naming something arbitrary.
394
+ if (!/^[0-9a-f]{32}$/.test(value)) {
395
+ return `Invalid --scope value '${value}'. Expected a 32-character index-scope id.`;
396
+ }
397
+ options.scope = value;
398
+ return null;
399
+ }
345
400
  default:
346
401
  return `Unknown flag '${flag}'.`;
347
402
  }
@@ -486,20 +541,16 @@ async function resolveAccess(deps) {
486
541
  }
487
542
  /** Run `git` with list args in the repository working directory. */
488
543
  function git(deps, args) {
489
- return Promise.resolve(deps.runCommand("git", args, { cwd: deps.cwd }));
490
- }
491
- /** The single trimmed line a `git rev-parse`-style command produced, or null. */
492
- function firstLine(result) {
493
- const value = result.stdout.split("\n")[0]?.trim() ?? "";
494
- return value.length === 0 ? null : value;
544
+ return runGit(cutProtocolDeps(deps), args);
495
545
  }
496
- /** The SHA from `git ls-remote --heads origin <ref>` output, or null. */
497
- function lsRemoteSha(result) {
498
- const line = firstLine(result);
499
- if (line === null)
500
- return null;
501
- const sha = line.split(/\s+/)[0]?.trim() ?? "";
502
- return sha.length === 0 ? null : sha;
546
+ /** The strict subset of the pilot's deps the shared cut protocol consumes. */
547
+ function cutProtocolDeps(deps) {
548
+ return {
549
+ runCommand: deps.runCommand,
550
+ cwd: deps.cwd,
551
+ fetchImpl: deps.fetchImpl,
552
+ errorLog: deps.errorLog,
553
+ };
503
554
  }
504
555
  function isRecord(value) {
505
556
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -566,7 +617,8 @@ export async function collectConductEpicInitPreflight(deps, options) {
566
617
  failures.push(accessResult.error);
567
618
  let baseBranch = options.baseBranch ?? null;
568
619
  let baseSha = null;
569
- let epicBranchAlreadyAtBase = false;
620
+ let cutCommitSha = null;
621
+ let epicBranchAlreadyAtCut = false;
570
622
  if (access !== null) {
571
623
  // (4) auto_merge_enabled, and (5) a non-vacuous required-check set.
572
624
  const readiness = await getConductorReadiness(access, deps.fetchImpl);
@@ -632,22 +684,34 @@ export async function collectConductEpicInitPreflight(deps, options) {
632
684
  else if (runState.status !== 404) {
633
685
  failures.push(`The epic-run state for ${options.epicKey} could not be read: ${runState.error}`);
634
686
  }
635
- // (10) Index-branch override: absent, or this epic's own (a re-init after a
636
- // crash). A foreign override is named so the operator knows which epic still
637
- // holds the repository's index.
638
- const indexBranch = await getIndexBranch(access, deps.fetchImpl);
639
- if (!indexBranch.ok) {
640
- failures.push(`The indexed-branch state could not be read: ${indexBranch.error}`);
687
+ // (10) BAPI-847: there is no repository-wide index-branch override to check
688
+ // any more. An epic no longer takes the repository's index away from anyone —
689
+ // it gets its OWN index scope — so two epics running at once is an ordinary
690
+ // state rather than a conflict a preflight has to detect. The default-base
691
+ // resolution below therefore never reads a stored "original" branch: nothing
692
+ // was ever repointed, so the repository's configured base IS the original.
693
+ // (11) The canonical index must have a usable commit (BAPI-843). This is the
694
+ // check that inverts the cut order: without a `succeeded` canonical parse
695
+ // publishing a commit, there is no commit to cut at, and cutting at the base
696
+ // tip instead is exactly the behavior this replaces. The failure text names
697
+ // the fix an operator can actually perform.
698
+ const parseStatus = await getParseStatus(access, deps.fetchImpl);
699
+ if (!parseStatus.ok) {
700
+ failures.push(`The canonical parse status could not be read: ${parseStatus.error}`);
701
+ }
702
+ else if (parseStatus.value.status !== "succeeded") {
703
+ failures.push(`The canonical index for ${access.repoName} has no successful parse ` +
704
+ `(status: ${String(parseStatus.value.status)}). Parse the repository first.`);
641
705
  }
642
706
  else {
643
- const override = indexBranch.value.override;
644
- if (override !== null && override.override_branch !== epicBranch) {
645
- failures.push(`The repository index is already repointed to '${override.override_branch}' by another epic. ` +
646
- "Run `conduct-epic finish` for that epic first.");
707
+ const indexed = normalizeCommitSha(parseStatus.value.indexed_commit_sha);
708
+ if (indexed === null) {
709
+ failures.push(`The canonical index for ${access.repoName} published no commit for its ` +
710
+ "last successful parse, so there is no commit to cut at. " +
711
+ "Parse the repository first.");
647
712
  }
648
- else if (override !== null && baseBranch === null) {
649
- // (8) default base: this epic's own override remembers the real original.
650
- baseBranch = override.original_base_branch;
713
+ else {
714
+ cutCommitSha = indexed;
651
715
  }
652
716
  }
653
717
  // (8) default base, continued: the configured base branch, then `main`.
@@ -665,8 +729,9 @@ export async function collectConductEpicInitPreflight(deps, options) {
665
729
  failures.push(`The resolved base branch is unusable: ${branchReason}`);
666
730
  }
667
731
  else {
668
- // (8) The base must exist on origin AFTER a fetch an epic branch cut from a
669
- // stale local ref silently starts the epic behind main.
732
+ // (8) The base is still fetched local git needs its object history, and the
733
+ // cut commit is almost always reachable from it — but the base tip is NO
734
+ // LONGER the branch source (BAPI-843). It is reported for context only.
670
735
  const fetched = await git(deps, ["fetch", "origin", baseBranch]);
671
736
  if (fetched.exitCode !== 0) {
672
737
  failures.push(`git fetch origin ${baseBranch} failed.`);
@@ -676,7 +741,28 @@ export async function collectConductEpicInitPreflight(deps, options) {
676
741
  if (baseSha === null) {
677
742
  failures.push(`origin/${baseBranch} does not exist after fetching.`);
678
743
  }
679
- // (9) `epic/<EPIC>` must be absent on origin, or already at the base tip.
744
+ if (cutCommitSha !== null) {
745
+ // (12) The EXACT cut object must be resolvable locally, because `init`
746
+ // pushes it by SHA. The base fetch usually brings it along; when it did
747
+ // not — the index covers a commit that is no longer an ancestor of the
748
+ // base tip — one targeted, NON-MUTATING fetch of that SHA is attempted (it
749
+ // updates no ref, creates no branch, and checks nothing out). Failing here
750
+ // is deliberate: it happens before the cut protocol, so nothing has been
751
+ // held, pushed, or recorded.
752
+ const present = await git(deps, ["rev-parse", "--verify", "--quiet", `${cutCommitSha}^{commit}`]);
753
+ if (present.exitCode !== 0) {
754
+ await git(deps, ["fetch", "origin", cutCommitSha]);
755
+ const retry = await git(deps, ["rev-parse", "--verify", "--quiet", `${cutCommitSha}^{commit}`]);
756
+ if (retry.exitCode !== 0) {
757
+ failures.push(`The canonical indexed commit ${cutCommitSha} could not be resolved locally ` +
758
+ `even after fetching it from origin. Fetch it manually, or re-parse ${access?.repoName ?? "the repository"}.`);
759
+ cutCommitSha = null;
760
+ }
761
+ }
762
+ }
763
+ // (9) `epic/<EPIC>` must be absent on origin, or already at exactly the
764
+ // canonical indexed commit. An epic branch sitting at ANY other commit still
765
+ // fails closed — including the base tip, which is no longer special.
680
766
  const existing = await git(deps, ["ls-remote", "--heads", "origin", `refs/heads/${epicBranch}`]);
681
767
  if (existing.exitCode !== 0) {
682
768
  failures.push(`git ls-remote could not read origin/${epicBranch}.`);
@@ -684,11 +770,12 @@ export async function collectConductEpicInitPreflight(deps, options) {
684
770
  else {
685
771
  const existingSha = lsRemoteSha(existing);
686
772
  if (existingSha !== null) {
687
- if (baseSha !== null && existingSha === baseSha) {
688
- epicBranchAlreadyAtBase = true;
773
+ if (cutCommitSha !== null && existingSha === cutCommitSha) {
774
+ epicBranchAlreadyAtCut = true;
689
775
  }
690
776
  else {
691
- failures.push(`origin/${epicBranch} already exists at a commit other than the ${baseBranch} tip. ` +
777
+ failures.push(`origin/${epicBranch} already exists at ${existingSha}, which is not the ` +
778
+ `canonical indexed commit${cutCommitSha ? ` ${cutCommitSha}` : ""}. ` +
692
779
  "Delete it or finish the previous run before re-initializing.");
693
780
  }
694
781
  }
@@ -713,7 +800,8 @@ export async function collectConductEpicInitPreflight(deps, options) {
713
800
  access,
714
801
  baseBranch,
715
802
  baseSha,
716
- epicBranchAlreadyAtBase,
803
+ cutCommitSha,
804
+ epicBranchAlreadyAtCut,
717
805
  pendingSupervisorConfig,
718
806
  };
719
807
  }
@@ -739,6 +827,80 @@ function requiredCheckNamesFromResolve(value) {
739
827
  }
740
828
  return names;
741
829
  }
830
+ /**
831
+ * Drive the scope from a recorded cut to `ready`, or report why it did not.
832
+ *
833
+ * Schedules the bootstrap (seed + verifying parse) and then POLLS the control
834
+ * plane, because readiness is a server-side fact this CLI observes rather than
835
+ * one it can conclude from its own request succeeding.
836
+ *
837
+ * Each terminal state maps to its own operator instruction, and the distinction
838
+ * matters: `canonical_index_advanced` means re-run `init` (the cut protocol
839
+ * re-drives at the newer commit), while a verification failure means the seed
840
+ * itself is wrong and re-cutting would just reproduce it.
841
+ */
842
+ async function driveIndexScopeBootstrap(deps, access, scopeId, options) {
843
+ const scheduled = await bootstrapIndexScope(access, { scopeId }, deps.fetchImpl);
844
+ if (!scheduled.ok) {
845
+ return { ok: false, failures: [`The index scope could not be seeded: ${scheduled.error}`] };
846
+ }
847
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
848
+ let lastState = "unknown";
849
+ for (let poll = 0; poll < SCOPE_BOOTSTRAP_MAX_POLLS; poll += 1) {
850
+ await sleep(SCOPE_BOOTSTRAP_POLL_INTERVAL_MS);
851
+ const status = await getIndexScopeStatus(access, scopeId, deps.fetchImpl);
852
+ if (!status.ok) {
853
+ // A transient read failure is not a verdict: keep polling and let the
854
+ // bound below be the thing that gives up.
855
+ lastState = `unreadable (${status.error})`;
856
+ continue;
857
+ }
858
+ lastState = status.value.lifecycle_state;
859
+ if (status.value.lifecycle_state === "ready") {
860
+ if (status.value.indexed_commit_sha !== null &&
861
+ status.value.indexed_commit_sha === status.value.cut_commit_sha) {
862
+ return { ok: true, failures: [] };
863
+ }
864
+ // `ready` is the server's verdict, and the server only promotes a scope
865
+ // whose watermark matches. Disagreeing here would mean the control plane
866
+ // contradicted itself, which is worth refusing rather than proceeding.
867
+ return {
868
+ ok: false,
869
+ failures: [
870
+ `Index scope ${scopeId} reports ready but its indexed commit ` +
871
+ `(${status.value.indexed_commit_sha ?? "none"}) is not the cut commit ` +
872
+ `(${status.value.cut_commit_sha ?? "none"}).`,
873
+ ],
874
+ };
875
+ }
876
+ if (status.value.lifecycle_state === "failed") {
877
+ const reason = status.value.last_error ?? "unknown";
878
+ if (reason === "canonical_index_advanced") {
879
+ return {
880
+ ok: false,
881
+ failures: [
882
+ `The canonical index advanced before the seed could run, so the scope was not seeded. ` +
883
+ `Delete origin/${epicBranchFor(options.epicKey)} and re-run init to cut at the newer commit.`,
884
+ ],
885
+ };
886
+ }
887
+ return {
888
+ ok: false,
889
+ failures: [
890
+ `Index scope ${scopeId} failed verification (${reason}). ` +
891
+ "The epic branch and its recorded cut are intact; re-run init to re-drive verification.",
892
+ ],
893
+ };
894
+ }
895
+ }
896
+ return {
897
+ ok: false,
898
+ failures: [
899
+ `Index scope ${scopeId} did not become ready within the bootstrap window ` +
900
+ `(last observed state: ${lastState}). Re-run init to resume verification.`,
901
+ ],
902
+ };
903
+ }
742
904
  /**
743
905
  * `conduct-epic init` — the only verb that provisions.
744
906
  *
@@ -772,13 +934,19 @@ export async function runConductEpicInit(deps, options) {
772
934
  });
773
935
  }
774
936
  const access = preflight.access;
775
- if (access === null || preflight.baseBranch === null || preflight.baseSha === null) {
937
+ if (access === null ||
938
+ preflight.baseBranch === null ||
939
+ preflight.baseSha === null ||
940
+ preflight.cutCommitSha === null) {
776
941
  // Unreachable: any of these being absent records a failure above. Guarded so
777
- // a future edit cannot turn a missing precondition into a push.
942
+ // a future edit cannot turn a missing precondition into a push — in
943
+ // particular a missing `cutCommitSha`, which would otherwise be a push at
944
+ // `undefined`.
778
945
  return emitFailure(deps, options.json, ["init preflight completed without a usable plan."], {
779
946
  epic_key: options.epicKey,
780
947
  });
781
948
  }
949
+ const cutCommitSha = preflight.cutCommitSha;
782
950
  // Starts as the preflight's own lines (which in `--dry-run` already include the
783
951
  // would-enable notice) and grows by at most the one success line below.
784
952
  const announcements = [...preflight.announcements];
@@ -787,8 +955,12 @@ export async function runConductEpicInit(deps, options) {
787
955
  const describePlan = () => [
788
956
  `epic: ${options.epicKey}`,
789
957
  `repo: ${access.repoName}`,
958
+ // The base branch and the cut commit are reported SEPARATELY and never
959
+ // conflated: the base is context (and the object history git needs), the cut
960
+ // is the commit the epic actually starts from.
790
961
  `base: ${preflight.baseBranch} @ ${preflight.baseSha}`,
791
- `branch: ${epicBranch}${preflight.epicBranchAlreadyAtBase ? " (already at the base tip)" : ""}`,
962
+ `cut: ${cutCommitSha} (canonical indexed commit)`,
963
+ `branch: ${epicBranch}${preflight.epicBranchAlreadyAtCut ? " (already at the cut commit)" : ""}`,
792
964
  `tickets: ${options.tickets.join(", ")}`,
793
965
  `checkpoint: ${checkpointPath}`,
794
966
  ...announcements,
@@ -801,10 +973,11 @@ export async function runConductEpicInit(deps, options) {
801
973
  epic_branch: epicBranch,
802
974
  base_branch: preflight.baseBranch,
803
975
  base_sha: preflight.baseSha,
976
+ cut_commit_sha: cutCommitSha,
804
977
  tickets: options.tickets,
805
978
  checkpoint_path: checkpointPath,
806
979
  announcements,
807
- }, ["Planned (dry run — nothing was pushed, repointed, or written):", ...describePlan()]);
980
+ }, ["Planned (dry run — nothing was pushed, cut, seeded, repointed, or written):", ...describePlan()]);
808
981
  }
809
982
  // The FIRST durable mutation of the whole verb, deliberately placed here: every
810
983
  // preflight check has passed, and nothing has been pushed, repointed, written,
@@ -820,31 +993,60 @@ export async function runConductEpicInit(deps, options) {
820
993
  }
821
994
  announcements.push(`announced: auto_merge_enabled was OFF and has been enabled on the ${access.repoName} project defaults.`);
822
995
  }
823
- // Push the epic branch WITHOUT a local checkout: the remote ref is created
824
- // directly from the fetched remote-tracking ref, so no local branch, worktree,
825
- // or checked-out state is touched.
826
- const pushed = await git(deps, [
827
- "push",
828
- "origin",
829
- `refs/remotes/origin/${preflight.baseBranch}:refs/heads/${epicBranch}`,
830
- ]);
831
- if (pushed.exitCode !== 0) {
832
- return emitFailure(deps, options.json, [`Could not create origin/${epicBranch} from the ${preflight.baseBranch} tip.`], {
833
- epic_key: options.epicKey,
834
- });
996
+ // --- The cut protocol (BAPI-843) ----------------------------------------
997
+ //
998
+ // Driven by the SHARED exact-cut module (BAPI-850): `cut/begin`, the re-check
999
+ // of `origin/epic/<EPIC>` under the hold, the exact-SHA push with the
1000
+ // operator's own git, the read-back, `cut/commit`, and the release on every
1001
+ // outcome all happen in `performExactIndexScopeCut`, while the SERVER holds
1002
+ // the canonical repository's parse lock so the commit the index covers cannot
1003
+ // move underneath the ref being created. The pilot creates no `epic_run`, so
1004
+ // it passes no run association.
1005
+ const cutOutcome = await performExactIndexScopeCut(cutProtocolDeps(deps), access, {
1006
+ featureBranch: epicBranch,
1007
+ baseBranch: preflight.baseBranch,
1008
+ candidateCommitSha: cutCommitSha,
1009
+ });
1010
+ if (!cutOutcome.ok) {
1011
+ // `begin` refusals carry the checkpoint path (nothing was leased); every
1012
+ // later refusal names only the epic, exactly as before the extraction.
1013
+ return emitFailure(deps, options.json, cutOutcome.failures, cutOutcome.kind === "begin_refused"
1014
+ ? { epic_key: options.epicKey, checkpoint_path: checkpointPath }
1015
+ : { epic_key: options.epicKey });
835
1016
  }
836
- const repointed = await repointIndexBranch(access, { branch: epicBranch }, deps.fetchImpl);
837
- if (!repointed.ok) {
838
- return emitFailure(deps, options.json, [`The repository index could not be repointed to ${epicBranch}: ${repointed.error}`], {
1017
+ // A successful outcome IS the recorded cut: the shared module returns `ok`
1018
+ // only after `cut/commit` accepted the ref, so there is no unrecorded-cut
1019
+ // state to guard against here.
1020
+ const cut = cutOutcome.lease;
1021
+ // --- Seed + verify, only after the hold is released ----------------------
1022
+ const scopeReady = await driveIndexScopeBootstrap(deps, access, cut.scope_id, options);
1023
+ if (!scopeReady.ok) {
1024
+ return emitFailure(deps, options.json, scopeReady.failures, {
839
1025
  epic_key: options.epicKey,
1026
+ scope_id: cut.scope_id,
1027
+ checkpoint_path: checkpointPath,
840
1028
  });
841
1029
  }
1030
+ announcements.push(`announced: index scope ${cut.scope_id} is ready at ${cut.cut_commit_sha}.`);
1031
+ // BAPI-847: `init` used to repoint the REPOSITORY's `base_branch` at the epic
1032
+ // branch here, which is the defect this epic exists to remove — a
1033
+ // repository-wide mutation that every unrelated run then resolved through. The
1034
+ // scope provisioned above already carries the epic branch on its own shadow
1035
+ // config row, so the canonical repository's configuration is never touched and
1036
+ // nothing has to be restored later.
1037
+ //
1038
+ // The checkpoint is still written LAST: it is only correct once the scope has
1039
+ // proven its own coverage.
842
1040
  const request = lockRequest(deps);
843
1041
  const checkpoint = createInitialConductEpicCheckpoint({
844
1042
  epicKey: options.epicKey,
845
1043
  repoName: access.repoName,
846
1044
  epicBranch,
847
1045
  baseBranchOriginal: preflight.baseBranch,
1046
+ // BAPI-844: the server-minted scope from THIS init's cut, recorded so every
1047
+ // later `spawn` declares the scope the epic was actually cut against. It is
1048
+ // stored exactly as returned; nothing local mints, edits, or defaults it.
1049
+ indexScopeId: cut.scope_id,
848
1050
  ticketKeys: options.tickets,
849
1051
  now: deps.now().toISOString(),
850
1052
  lock: { owner_pid: request.ownerPid, host: request.host, acquired_at: request.acquiredAt },
@@ -870,10 +1072,11 @@ export async function runConductEpicInit(deps, options) {
870
1072
  epic_branch: epicBranch,
871
1073
  base_branch: preflight.baseBranch,
872
1074
  base_sha: preflight.baseSha,
1075
+ cut_commit_sha: cutCommitSha,
1076
+ scope_id: cut.scope_id,
873
1077
  tickets: options.tickets,
874
1078
  checkpoint_path: checkpointPath,
875
1079
  lock_path: resolveConductEpicLockPath(checkpointPath),
876
- index_repointed: true,
877
1080
  announcements,
878
1081
  }, ["Initialized:", ...describePlan()]);
879
1082
  }
@@ -1054,6 +1257,102 @@ export async function runConductEpicStatus(deps, options) {
1054
1257
  parse = normalizeParseStatus(parseStatus.value);
1055
1258
  }
1056
1259
  }
1260
+ // --- scope (BAPI-845) ----------------------------------------------------
1261
+ // The pilot's freshness evidence, asked DIRECTLY. `parse` above is
1262
+ // repository-level and says nothing about whether *this* merge was indexed,
1263
+ // which is why Row 5 used to reconstruct causality from a request timestamp and
1264
+ // a head SHA. This probe replaces that inference with the scope's own answer:
1265
+ // its lifecycle, both watermarks, and a bounded freshness verdict.
1266
+ //
1267
+ // Absent only when the epic declares no scope — a pilot epic cut before
1268
+ // BAPI-843, or a non-scope run. That is a calm `null` with no probe error,
1269
+ // because there is nothing to report rather than something we failed to read.
1270
+ let scope = null;
1271
+ const declaredScopeId = typeof checkpoint.index_scope_id === "string" && checkpoint.index_scope_id.length > 0
1272
+ ? checkpoint.index_scope_id
1273
+ : null;
1274
+ if (access !== null && declaredScopeId !== null) {
1275
+ const scopeStatus = await getIndexScopeStatus(access, declaredScopeId, deps.fetchImpl);
1276
+ if (!scopeStatus.ok) {
1277
+ probeErrors.push({ probe: "scope", reason: scopeStatus.error });
1278
+ // An unread scope is reported as explicitly `unavailable` rather than left
1279
+ // null: a null could be read as "no scope declared", and that reading would
1280
+ // let the loop proceed past a freshness question it never got an answer to.
1281
+ scope = {
1282
+ scope_id: declaredScopeId,
1283
+ lifecycle_state: null,
1284
+ freshness_status: "unavailable",
1285
+ blocked_reason: null,
1286
+ required_commit_sha: null,
1287
+ indexed_commit_sha: null,
1288
+ last_error: null,
1289
+ };
1290
+ }
1291
+ else {
1292
+ scope = {
1293
+ scope_id: scopeStatus.value.scope_id,
1294
+ lifecycle_state: scopeStatus.value.lifecycle_state,
1295
+ // Fail closed: the client already narrowed an unrecognized value to null,
1296
+ // and null here means "not fresh", never "fine".
1297
+ freshness_status: scopeStatus.value.freshness_status ?? "unavailable",
1298
+ blocked_reason: scopeStatus.value.blocked_reason,
1299
+ required_commit_sha: scopeStatus.value.required_commit_sha,
1300
+ indexed_commit_sha: scopeStatus.value.indexed_commit_sha,
1301
+ last_error: scopeStatus.value.last_error,
1302
+ };
1303
+ }
1304
+ }
1305
+ // --- BAPI-846: the repository's index scopes, and this epic's heartbeat ---
1306
+ //
1307
+ // TWO distinct jobs, both belonging here rather than in a daemon:
1308
+ //
1309
+ // 1. The LISTING makes a crashed epic's scope discoverable. `scope` above
1310
+ // answers "is MY index fresh?"; this answers "what index scopes exist, and
1311
+ // which of them is stranded?" — including expired, reclaiming, and reclaimed
1312
+ // ones, which is what makes a stale scope actionable without SQL.
1313
+ // 2. The HEARTBEAT renews this epic's lease. The pilot has no long-lived local
1314
+ // process — `/loop 5m /conduct-epic` re-invokes this command on a timer, so
1315
+ // the tick loop IS the heartbeat owner, and its cadence matches the default
1316
+ // heartbeat interval. Beating from here means ownership is renewed exactly
1317
+ // while a conductor is actively driving the epic, and stops the moment it
1318
+ // stops ticking, with no untracked daemon and no shutdown contract to get
1319
+ // wrong.
1320
+ //
1321
+ // Both are probes: a failure lands in `probe_errors` and never fails `status`.
1322
+ let scopes = [];
1323
+ let retentionSeconds = null;
1324
+ let nextLeaseEpoch = checkpoint.index_scope_lease_epoch;
1325
+ if (access !== null) {
1326
+ const listing = await getIndexScopeLifecycle(access, deps.fetchImpl);
1327
+ if (!listing.ok) {
1328
+ probeErrors.push({ probe: "scopes", reason: listing.error });
1329
+ }
1330
+ else {
1331
+ scopes = listing.value.scopes;
1332
+ retentionSeconds = listing.value.retention_seconds;
1333
+ }
1334
+ if (declaredScopeId !== null) {
1335
+ // The epoch comes from the SERVER's listing when it is readable, and from
1336
+ // the checkpoint only as a fallback. Preferring the server is what lets a
1337
+ // conductor keep beating after an operator `recover` superseded its
1338
+ // generation — the alternative is a healthy conductor permanently fenced by
1339
+ // a stale local number.
1340
+ const entry = scopes.find((scope) => scope.scope_id === declaredScopeId);
1341
+ const epoch = entry?.lease_epoch ?? checkpoint.index_scope_lease_epoch;
1342
+ if (epoch !== null && entry?.recoverable !== false) {
1343
+ const beat = await heartbeatIndexScope(access, { scopeId: declaredScopeId, leaseEpoch: epoch }, deps.fetchImpl);
1344
+ if (beat.ok) {
1345
+ nextLeaseEpoch = beat.value.lease_epoch;
1346
+ }
1347
+ else {
1348
+ // A rejected beat means fenced or retired. Recorded as a probe error so
1349
+ // the loop SEES it, not swallowed — but never fatal, because `status`
1350
+ // is also how an operator finds out they were fenced.
1351
+ probeErrors.push({ probe: "scope_heartbeat", reason: beat.error });
1352
+ }
1353
+ }
1354
+ }
1355
+ }
1057
1356
  // --- lock (inspected, NEVER acquired) ------------------------------------
1058
1357
  const lockState = await inspectConductEpicLock(resolveConductEpicLockPath(checkpointPath), lockRequest(deps), buildConductEpicLockSeams(deps));
1059
1358
  const lock = {
@@ -1066,9 +1365,14 @@ export async function runConductEpicStatus(deps, options) {
1066
1365
  ? false
1067
1366
  : null,
1068
1367
  };
1069
- // --- the four permitted writes -------------------------------------------
1368
+ // --- the five permitted writes -------------------------------------------
1369
+ // BAPI-846 added the fifth: the scope's fencing epoch, refreshed from the
1370
+ // server's authoritative answer. It rides in the SAME atomic write as the other
1371
+ // four rather than in a second one, so a tick either records everything it
1372
+ // observed or nothing.
1070
1373
  let lastSeenHead = ticket?.last_seen_head ?? null;
1071
1374
  let lastStateChangeAt = ticket?.last_state_change_at ?? null;
1375
+ const leaseEpochChanged = nextLeaseEpoch !== checkpoint.index_scope_lease_epoch;
1072
1376
  if (ticket !== null) {
1073
1377
  const next = { ...checkpoint, tickets: [...checkpoint.tickets] };
1074
1378
  const index = next.tickets.findIndex((entry) => entry.key === ticket.key);
@@ -1090,6 +1394,10 @@ export async function runConductEpicStatus(deps, options) {
1090
1394
  next.ci_last_poll = ci.ci_last_poll;
1091
1395
  dirty = true;
1092
1396
  }
1397
+ if (leaseEpochChanged) {
1398
+ next.index_scope_lease_epoch = nextLeaseEpoch;
1399
+ dirty = true;
1400
+ }
1093
1401
  if (dirty) {
1094
1402
  next.updated_at = now.toISOString();
1095
1403
  const written = await writeConductEpicCheckpointAtomic(checkpointPath, next, deps.fs, {
@@ -1106,18 +1414,7 @@ export async function runConductEpicStatus(deps, options) {
1106
1414
  checkpoint_path: checkpointPath,
1107
1415
  checkpoint_exists: true,
1108
1416
  all_done: allDone,
1109
- ticket: ticket === null
1110
- ? null
1111
- : {
1112
- key: ticket.key,
1113
- status: ticket.status,
1114
- branch: discoveredBranch,
1115
- pr_number: ticket.pr_number,
1116
- spawned_at: ticket.spawned_at,
1117
- respawns: ticket.respawns,
1118
- conflict_attempts: ticket.conflict_attempts,
1119
- counters: { ...ticket.counters },
1120
- },
1417
+ ticket: ticket === null ? null : projectConductEpicTicketFacts(ticket, discoveredBranch),
1121
1418
  worktree_path: worktreePath,
1122
1419
  worktree_exists: worktreeExists,
1123
1420
  branch_head: branchHead,
@@ -1135,11 +1432,128 @@ export async function runConductEpicStatus(deps, options) {
1135
1432
  hard_seconds: checkpoint.deadlines.hard_seconds,
1136
1433
  elapsed_since_spawn_seconds: elapsedSeconds(ticket?.spawned_at ?? null, now),
1137
1434
  },
1435
+ scope,
1436
+ scope_lease_epoch: nextLeaseEpoch,
1437
+ retention_seconds: retentionSeconds,
1438
+ scopes,
1138
1439
  lock,
1139
1440
  needs_human: checkpoint.needs_human,
1140
1441
  probe_errors: probeErrors,
1141
1442
  };
1142
- return emitSuccess(deps, options.json, payload);
1443
+ return emitSuccess(deps, options.json, payload, [
1444
+ ...renderScopeFreshnessLines(scope),
1445
+ ...renderStrandedScopeLines(scopes, declaredScopeId),
1446
+ ]);
1447
+ }
1448
+ /**
1449
+ * Render the compact freshness unit for the human-readable `status` output.
1450
+ *
1451
+ * There is no dashboard for an index scope and this deliberately does not invent
1452
+ * one — the unit lives on the status surface that already exists. Its shape is
1453
+ * fixed by what an operator needs to decide, in that order:
1454
+ *
1455
+ * 1. **What is happening to the ticket**, first and in plain language. "Waiting
1456
+ * for index refresh" is the answer to the question actually being asked; a
1457
+ * lifecycle name is not.
1458
+ * 2. **The lifecycle**, then the two commits on SEPARATE, SEPARATELY LABELLED
1459
+ * lines. Printing them together, or printing only one, is what let "the commit
1460
+ * we must index" read as "the commit we indexed".
1461
+ * 3. **The refusal, spelled out**, when there is one. A controlled token is
1462
+ * precise but not self-explaining, so each is given a sentence — and each
1463
+ * sentence distinguishes it from a plain parse failure.
1464
+ *
1465
+ * Returns an empty array when the epic declares no scope, so an epic without one
1466
+ * prints exactly what it printed before.
1467
+ */
1468
+ /**
1469
+ * Render the STRANDED-SCOPE warning unit for `status` (BAPI-846).
1470
+ *
1471
+ * Deliberately narrow: it names only scopes that are not this epic's and are not
1472
+ * live — the crashed-epic case an operator can act on — and it says nothing at
1473
+ * all when there are none. A full inventory belongs in the JSON payload; the
1474
+ * human output exists to make one specific problem impossible to miss, and a
1475
+ * block that prints on every healthy tick is a block operators stop reading.
1476
+ *
1477
+ * `reclaimed` scopes are omitted: a tombstone is a completed outcome, not
1478
+ * something to act on.
1479
+ */
1480
+ function renderStrandedScopeLines(scopes, ownScopeId) {
1481
+ const stranded = scopes.filter((scope) => scope.scope_id !== ownScopeId &&
1482
+ scope.lifecycle_state !== "reclaimed" &&
1483
+ !scope.lease_valid);
1484
+ if (stranded.length === 0)
1485
+ return [];
1486
+ const lines = [
1487
+ `${stranded.length} index scope(s) in this repository have no live lease:`,
1488
+ ];
1489
+ for (const scope of stranded) {
1490
+ const action = scope.recoverable
1491
+ ? "recoverable — `conduct-epic recover <EPIC> --scope " + scope.scope_id + "`"
1492
+ : scope.retention_elapsed
1493
+ ? "past retention — the sweep will reclaim it"
1494
+ : `retained until ${scope.retention_deadline ?? "an unknown deadline"}`;
1495
+ lines.push(` ${scope.scope_id} ${scope.lifecycle_state ?? "unknown"} ` +
1496
+ `branch=${scope.feature_branch ?? "unknown"} ${action}`);
1497
+ if (scope.blockers.length > 0) {
1498
+ lines.push(` blocked by: ${scope.blockers.join(", ")}`);
1499
+ }
1500
+ }
1501
+ return lines;
1502
+ }
1503
+ function renderScopeFreshnessLines(scope) {
1504
+ if (scope === null)
1505
+ return [];
1506
+ const headline = {
1507
+ fresh: "Index is fresh for this epic.",
1508
+ pending: "Waiting for index refresh.",
1509
+ blocked: "Index refresh is BLOCKED — this advance will not be indexed.",
1510
+ failed: "Index generation FAILED for this scope.",
1511
+ unavailable: "Index freshness is unavailable — treat as not fresh.",
1512
+ };
1513
+ const refusal = {
1514
+ advance_blocked_base_merge: "the base branch was merged forward into the epic branch, which would move the branch's pinned cut point",
1515
+ advance_blocked_unexpected_parent: "the merge commit does not descend directly from the head this scope pinned, so it is not a worker merge",
1516
+ advance_blocked_history_changed: "the pinned head is gone from the branch's history — a force-push or rewrite",
1517
+ advance_blocked_unverifiable: "the advance could not be verified at all, and doubt blocks rather than indexes",
1518
+ };
1519
+ const lines = [
1520
+ headline[scope.freshness_status] ?? "Index freshness is unknown — treat as not fresh.",
1521
+ ` lifecycle: ${scope.lifecycle_state ?? "unknown"}`,
1522
+ ` Required commit: ${scope.required_commit_sha ?? "none"}`,
1523
+ ` Indexed commit: ${scope.indexed_commit_sha ?? "none"}`,
1524
+ ];
1525
+ if (scope.blocked_reason !== null) {
1526
+ lines.push(` Reason: ${scope.blocked_reason} — ${refusal[scope.blocked_reason] ?? "the server refused this branch advance"}`);
1527
+ lines.push(" A human must resolve the branch before the epic can continue.");
1528
+ }
1529
+ else if (scope.freshness_status === "failed" && scope.last_error !== null) {
1530
+ lines.push(` Failure category: ${scope.last_error}`);
1531
+ }
1532
+ return lines;
1533
+ }
1534
+ /**
1535
+ * Project one checkpoint ticket into the published `ticket` facts.
1536
+ *
1537
+ * `branch` comes from the caller's freshly discovered value rather than the
1538
+ * stored one, matching the rest of the status contract. `journal` is copied so
1539
+ * a consumer of the payload can never reach back into checkpoint state — the
1540
+ * status verb's write allowlist is deliberately narrow, and handing out a live
1541
+ * array reference would be a fifth, undeclared write path.
1542
+ */
1543
+ export function projectConductEpicTicketFacts(ticket, discoveredBranch) {
1544
+ return {
1545
+ key: ticket.key,
1546
+ status: ticket.status,
1547
+ branch: discoveredBranch,
1548
+ pr_number: ticket.pr_number,
1549
+ spawned_at: ticket.spawned_at,
1550
+ parse_requested_at: ticket.parse_requested_at,
1551
+ parse_requested_for_sha: ticket.parse_requested_for_sha,
1552
+ respawns: ticket.respawns,
1553
+ conflict_attempts: ticket.conflict_attempts,
1554
+ counters: { ...ticket.counters },
1555
+ journal: [...ticket.journal],
1556
+ };
1143
1557
  }
1144
1558
  /**
1145
1559
  * Poll CI for `headSha`, warming the resolve cache at most once.
@@ -1304,7 +1718,6 @@ export function normalizeParseStatus(value) {
1304
1718
  terminal: status === "succeeded" || status === "failed",
1305
1719
  started_at: optionalText("started_at"),
1306
1720
  finished_at: optionalText("finished_at"),
1307
- index_branch_override: optionalText("index_branch_override"),
1308
1721
  };
1309
1722
  }
1310
1723
  // ---------------------------------------------------------------------------
@@ -1455,6 +1868,22 @@ function applyFieldAssignment(checkpoint, ticketIndex, assignment) {
1455
1868
  ticket.spawned_at = value;
1456
1869
  return null;
1457
1870
  }
1871
+ case "parse_requested_at":
1872
+ case "parse_requested_for_sha": {
1873
+ // The durable half of the post-merge causal barrier (BAPI-825/A2). Both
1874
+ // follow the module's nullable-string convention so a tick can clear them
1875
+ // as deliberately as it sets them — an operator re-running a parse writes
1876
+ // `null` here rather than hand-editing the checkpoint.
1877
+ if (value === "null") {
1878
+ ticket[name] = null;
1879
+ return null;
1880
+ }
1881
+ if (value.trim().length === 0) {
1882
+ return `${name} must be a non-empty string or null.`;
1883
+ }
1884
+ ticket[name] = value;
1885
+ return null;
1886
+ }
1458
1887
  case "respawns":
1459
1888
  case "conflict_attempts": {
1460
1889
  const parsed = parseIntegerField(value);
@@ -1587,12 +2016,27 @@ export async function runConductEpicSpawn(deps, options) {
1587
2016
  catch {
1588
2017
  return emitFailure(deps, options.json, [`The prompt file '${options.promptFile}' could not be read.`]);
1589
2018
  }
2019
+ // BAPI-844: the scope this epic was cut against, read ONLY from the durable
2020
+ // server-returned value on the checkpoint. `process.env.BAPI_INDEX_SCOPE` is
2021
+ // deliberately not consulted — an operator's shell is not the epic's routing
2022
+ // decision, and a pilot ticket has no server-side membership to fall back on,
2023
+ // so an ambient value would silently route this worker's research somewhere
2024
+ // nobody chose. A malformed recorded value stops the spawn with the fixed
2025
+ // configuration error rather than launching an unscoped (canonical) worker.
2026
+ let indexScope;
2027
+ try {
2028
+ indexScope = validateOptionalIndexScope(checkpoint.index_scope_id);
2029
+ }
2030
+ catch {
2031
+ return emitFailure(deps, options.json, [INDEX_SCOPE_CONFIGURATION_ERROR]);
2032
+ }
1590
2033
  const spawned = await spawnConductEpicAgentTab({
1591
2034
  ticketKey,
1592
2035
  worktreePath: found.path,
1593
2036
  prompt,
1594
2037
  agent: options.agent,
1595
2038
  platform: deps.platform,
2039
+ ...(indexScope === undefined ? {} : { indexScope }),
1596
2040
  }, deps.spawnTab);
1597
2041
  if (!spawned.ok)
1598
2042
  return emitFailure(deps, options.json, [spawned.error]);
@@ -1634,14 +2078,14 @@ export async function runConductEpicSpawn(deps, options) {
1634
2078
  // finish
1635
2079
  // ---------------------------------------------------------------------------
1636
2080
  /**
1637
- * `conduct-epic finish` — restore the server's indexed base branch and wind down.
2081
+ * `conduct-epic finish` — retire the epic's index scope and wind down.
1638
2082
  *
1639
- * Restoration uses `restoreIndexBranch(access)` and NOTHING else. The
1640
- * checkpoint's `base_branch_original` is display-only: the server holds the
1641
- * durable override row with the true original, and a local copy that drifted
1642
- * (because the operator changed the repository's base mid-run) would restore the
1643
- * index to the wrong branch. `{ ok: true, changed: false }` is a success that
1644
- * is what a second `finish` sees.
2083
+ * BAPI-847: there is nothing to RESTORE. `init` no longer repoints the
2084
+ * repository's `base_branch`, so `finish` has no repository-wide configuration
2085
+ * to put back it retires the scope the epic was cut against and reports that
2086
+ * scope's lifecycle outcome and nothing else. The checkpoint's
2087
+ * `base_branch_original` remains display-only: it records which base the epic was
2088
+ * cut from, never a value this verb writes anywhere.
1645
2089
  */
1646
2090
  export async function runConductEpicFinish(deps, options) {
1647
2091
  const accessProbe = await resolveAccess(deps);
@@ -1660,19 +2104,44 @@ export async function runConductEpicFinish(deps, options) {
1660
2104
  if (!lock.acquired) {
1661
2105
  return emitFailure(deps, options.json, [`The epic lock could not be acquired: ${lock.reason}`]);
1662
2106
  }
1663
- const restored = await restoreIndexBranch(access, deps.fetchImpl);
1664
- if (!restored.ok) {
1665
- await releaseAcquired(lock);
1666
- return emitFailure(deps, options.json, [`The repository index could not be restored: ${restored.error}`]);
2107
+ // BAPI-846: `finish` RETIRES the epic's index scope; it never deletes it. The
2108
+ // scope keeps every Postgres row and every Pinecone namespace and stays
2109
+ // readable for post-mortem for the whole retention window — deletion is always
2110
+ // the scheduled sweep's or an explicit `reclaim`'s.
2111
+ //
2112
+ // Retirement runs INSIDE the lock, before it is released, so a concurrent
2113
+ // `finish` cannot interleave with it. It calls the retire API and never a
2114
+ // namespace or database deletion, and enough local state is retained (the
2115
+ // checkpoint keeps `index_scope_id` and the epoch) that a failed retirement can
2116
+ // simply be retried with `conduct-epic retire`.
2117
+ let scopeRetired = null;
2118
+ let scopeRetirementError = null;
2119
+ if (typeof checkpoint.index_scope_id === "string" && checkpoint.index_scope_id.length > 0) {
2120
+ const retirement = await retireScopeWithEpoch(deps, options, access, {
2121
+ scopeId: checkpoint.index_scope_id,
2122
+ checkpoint,
2123
+ leaseEpoch: checkpoint.index_scope_lease_epoch,
2124
+ });
2125
+ scopeRetired = retirement.ok;
2126
+ if (!retirement.ok)
2127
+ scopeRetirementError = retirement.reason;
1667
2128
  }
1668
2129
  await releaseAcquired(lock);
2130
+ // A failed retirement does NOT fail `finish`: the lock is already released and
2131
+ // every other wind-down step has happened, so reporting failure would invite a
2132
+ // re-run that redoes work already done. It is surfaced instead, with the one
2133
+ // command that fixes it — and the scope enters retention on lease expiry
2134
+ // regardless.
2135
+ if (scopeRetirementError !== null) {
2136
+ deps.errorLog(`The index scope was not retired: ${scopeRetirementError}. ` +
2137
+ `Retry with \`conduct-epic retire ${checkpoint.epic_key}\`.`);
2138
+ }
1669
2139
  const summary = {
1670
2140
  ok: true,
1671
2141
  epic_key: checkpoint.epic_key,
1672
2142
  epic_branch: checkpoint.epic_branch,
1673
- index_restored: true,
1674
- index_changed: restored.value.changed,
1675
- current_base_branch: restored.value.current_base_branch,
2143
+ /** `null` when the epic declares no scope; `false` when retirement failed. */
2144
+ scope_retired: scopeRetired,
1676
2145
  counters: { ...checkpoint.counters },
1677
2146
  needs_human: checkpoint.needs_human,
1678
2147
  tickets: checkpoint.tickets.map((ticket) => ({
@@ -1684,7 +2153,11 @@ export async function runConductEpicFinish(deps, options) {
1684
2153
  };
1685
2154
  const humanLines = [
1686
2155
  `Finished ${checkpoint.epic_key} (${checkpoint.epic_branch})`,
1687
- `index restore: ${restored.value.changed ? "restored" : "already restored"}`,
2156
+ `index scope: ${scopeRetired === null
2157
+ ? "none declared"
2158
+ : scopeRetired
2159
+ ? "retired (retention clock started; nothing deleted)"
2160
+ : "NOT retired — see the error above"}`,
1688
2161
  `iterations: ${checkpoint.counters.iterations} merges: ${checkpoint.counters.merges}`,
1689
2162
  ...checkpoint.tickets.map((ticket) => ` ${ticket.key} ${ticket.status} PR ${ticket.pr_number ?? "-"} ` +
1690
2163
  `spawned ${ticket.counters.sessions_spawned} plans ${ticket.counters.plan_generations_observed} ` +
@@ -1694,6 +2167,213 @@ export async function runConductEpicFinish(deps, options) {
1694
2167
  return emitSuccess(deps, options.json, summary, humanLines);
1695
2168
  }
1696
2169
  // ---------------------------------------------------------------------------
2170
+ // Index-scope lifecycle verbs (BAPI-846)
2171
+ // ---------------------------------------------------------------------------
2172
+ //
2173
+ // `recover`, `retire`, and `reclaim` all reach the authenticated Bridge API and
2174
+ // nothing else. There is deliberately no local Pinecone client, no SQL, and no
2175
+ // deletion path in this process: the server owns every destructive decision, and
2176
+ // a CLI that could delete directly would be a second authority with none of the
2177
+ // server's fencing, locking, or blocker checks.
2178
+ //
2179
+ // Each one persists the server's returned fencing epoch into the protected
2180
+ // checkpoint — never into argv or stdout — so a later heartbeat or retirement
2181
+ // uses the generation the server actually minted.
2182
+ /**
2183
+ * Resolve which scope a lifecycle verb targets.
2184
+ *
2185
+ * `--scope` wins when supplied; otherwise the epic's own scope is read from the
2186
+ * checkpoint. The explicit flag exists for the case the discovery surface is FOR:
2187
+ * a crashed epic whose local checkpoint is gone or was never written, whose scope
2188
+ * an operator found on `status`.
2189
+ */
2190
+ async function resolveLifecycleScope(deps, options, checkpointPath) {
2191
+ if (options.scope !== undefined) {
2192
+ // An explicitly named scope carries no local lease state, so any epoch must
2193
+ // come from the server. Reading one from an unrelated checkpoint would send a
2194
+ // generation that belongs to a different scope.
2195
+ return { ok: true, scopeId: options.scope, checkpoint: null, leaseEpoch: null };
2196
+ }
2197
+ const read = await readConductEpicCheckpoint(checkpointPath, deps.fs);
2198
+ if (read.kind === "missing") {
2199
+ return {
2200
+ ok: false,
2201
+ reason: `No checkpoint exists at ${checkpointPath}. ` +
2202
+ "Pass --scope <id> to target a scope directly (see `conduct-epic status`).",
2203
+ };
2204
+ }
2205
+ if (read.kind !== "ok")
2206
+ return { ok: false, reason: read.error };
2207
+ const scopeId = read.checkpoint.index_scope_id;
2208
+ if (typeof scopeId !== "string" || scopeId.length === 0) {
2209
+ return { ok: false, reason: `${options.epicKey} declares no index scope.` };
2210
+ }
2211
+ return {
2212
+ ok: true,
2213
+ scopeId,
2214
+ checkpoint: read.checkpoint,
2215
+ leaseEpoch: read.checkpoint.index_scope_lease_epoch,
2216
+ };
2217
+ }
2218
+ /**
2219
+ * Persist the server's authoritative fencing epoch into the checkpoint.
2220
+ *
2221
+ * Best-effort by design: the lifecycle call already succeeded on the server, and
2222
+ * failing the command because a local cache write failed would report a
2223
+ * successful recovery as a failure. The next `status` re-reads the epoch anyway.
2224
+ */
2225
+ async function persistScopeLeaseEpoch(deps, checkpointPath, checkpoint, leaseEpoch) {
2226
+ if (checkpoint === null || leaseEpoch === null)
2227
+ return;
2228
+ if (checkpoint.index_scope_lease_epoch === leaseEpoch)
2229
+ return;
2230
+ const next = {
2231
+ ...checkpoint,
2232
+ index_scope_lease_epoch: leaseEpoch,
2233
+ updated_at: deps.now().toISOString(),
2234
+ };
2235
+ await writeConductEpicCheckpointAtomic(checkpointPath, next, deps.fs, {
2236
+ skipChmod: deps.platform === "win32",
2237
+ });
2238
+ }
2239
+ /** `conduct-epic recover` — take a new ownership generation for a scope. */
2240
+ export async function runConductEpicRecover(deps, options) {
2241
+ const accessProbe = await resolveAccess(deps);
2242
+ if (!accessProbe.ok)
2243
+ return emitFailure(deps, options.json, [accessProbe.error]);
2244
+ const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
2245
+ const target = await resolveLifecycleScope(deps, options, checkpointPath);
2246
+ if (!target.ok)
2247
+ return emitFailure(deps, options.json, [target.reason]);
2248
+ const recovered = await recoverIndexScope(accessProbe.access, { scopeId: target.scopeId }, deps.fetchImpl);
2249
+ if (!recovered.ok) {
2250
+ return emitFailure(deps, options.json, [
2251
+ `The index scope could not be recovered: ${recovered.error}`,
2252
+ ]);
2253
+ }
2254
+ await persistScopeLeaseEpoch(deps, checkpointPath, target.checkpoint, recovered.value.lease_epoch);
2255
+ return emitSuccess(deps, options.json, {
2256
+ ok: true,
2257
+ epic_key: options.epicKey,
2258
+ scope_id: recovered.value.scope_id,
2259
+ lifecycle_state: recovered.value.lifecycle_state,
2260
+ lease_epoch: recovered.value.lease_epoch,
2261
+ lease_expires_at: recovered.value.lease_expires_at,
2262
+ }, [
2263
+ `Recovered index scope ${recovered.value.scope_id}.`,
2264
+ ` lifecycle: ${recovered.value.lifecycle_state ?? "unknown"}`,
2265
+ ` lease epoch: ${recovered.value.lease_epoch ?? "unknown"} (previous owners are now fenced)`,
2266
+ ` lease expires: ${recovered.value.lease_expires_at ?? "unknown"}`,
2267
+ ]);
2268
+ }
2269
+ /** `conduct-epic retire` — start the retention clock; delete nothing. */
2270
+ export async function runConductEpicRetire(deps, options) {
2271
+ const accessProbe = await resolveAccess(deps);
2272
+ if (!accessProbe.ok)
2273
+ return emitFailure(deps, options.json, [accessProbe.error]);
2274
+ const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
2275
+ const target = await resolveLifecycleScope(deps, options, checkpointPath);
2276
+ if (!target.ok)
2277
+ return emitFailure(deps, options.json, [target.reason]);
2278
+ const outcome = await retireScopeWithEpoch(deps, options, accessProbe.access, target);
2279
+ if (!outcome.ok)
2280
+ return emitFailure(deps, options.json, [outcome.reason]);
2281
+ return emitSuccess(deps, options.json, {
2282
+ ok: true,
2283
+ epic_key: options.epicKey,
2284
+ scope_id: outcome.state.scope_id,
2285
+ lifecycle_state: outcome.state.lifecycle_state,
2286
+ lease_epoch: outcome.state.lease_epoch,
2287
+ already_retired: outcome.state.already_retired,
2288
+ }, [
2289
+ outcome.state.already_retired
2290
+ ? `Index scope ${outcome.state.scope_id} was already retired; retention clock unchanged.`
2291
+ : `Retired index scope ${outcome.state.scope_id}. Nothing was deleted.`,
2292
+ " The scope stays readable for post-mortem for the whole retention window.",
2293
+ ]);
2294
+ }
2295
+ /**
2296
+ * Retire a scope, resolving the fencing epoch the server currently holds.
2297
+ *
2298
+ * The epoch is the one thing retirement needs and the one thing a local
2299
+ * checkpoint can be wrong about — an operator `recover` (or another conductor)
2300
+ * may have superseded it. So a locally-cached epoch is used only as a first
2301
+ * attempt; on a fencing refusal the CURRENT epoch is read from the server's
2302
+ * listing and the retirement is retried ONCE. That is not a retry loop papering
2303
+ * over a race: retirement is idempotent and the second attempt uses an epoch the
2304
+ * server itself just reported.
2305
+ */
2306
+ async function retireScopeWithEpoch(deps, options, access, target) {
2307
+ let epoch = target.leaseEpoch;
2308
+ if (epoch === null) {
2309
+ const current = await lookupScopeEpoch(deps, access, target.scopeId);
2310
+ if (current === null) {
2311
+ return {
2312
+ ok: false,
2313
+ reason: `The current fencing epoch for scope ${target.scopeId} could not be read.`,
2314
+ };
2315
+ }
2316
+ epoch = current;
2317
+ }
2318
+ let retired = await retireIndexScope(access, { scopeId: target.scopeId, leaseEpoch: epoch }, deps.fetchImpl);
2319
+ if (!retired.ok) {
2320
+ const current = await lookupScopeEpoch(deps, access, target.scopeId);
2321
+ if (current !== null && current !== epoch) {
2322
+ epoch = current;
2323
+ retired = await retireIndexScope(access, { scopeId: target.scopeId, leaseEpoch: epoch }, deps.fetchImpl);
2324
+ }
2325
+ }
2326
+ if (!retired.ok) {
2327
+ return { ok: false, reason: `The index scope could not be retired: ${retired.error}` };
2328
+ }
2329
+ const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
2330
+ await persistScopeLeaseEpoch(deps, checkpointPath, target.checkpoint, retired.value.lease_epoch);
2331
+ return { ok: true, state: retired.value };
2332
+ }
2333
+ /** Read one scope's CURRENT fencing epoch from the authoritative listing. */
2334
+ async function lookupScopeEpoch(deps, access, scopeId) {
2335
+ const listing = await getIndexScopeLifecycle(access, deps.fetchImpl);
2336
+ if (!listing.ok)
2337
+ return null;
2338
+ const entry = listing.value.scopes.find((scope) => scope.scope_id === scopeId);
2339
+ return entry?.lease_epoch ?? null;
2340
+ }
2341
+ /** `conduct-epic reclaim` — ask the server to schedule the scope's teardown. */
2342
+ export async function runConductEpicReclaim(deps, options) {
2343
+ const accessProbe = await resolveAccess(deps);
2344
+ if (!accessProbe.ok)
2345
+ return emitFailure(deps, options.json, [accessProbe.error]);
2346
+ const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
2347
+ const target = await resolveLifecycleScope(deps, options, checkpointPath);
2348
+ if (!target.ok)
2349
+ return emitFailure(deps, options.json, [target.reason]);
2350
+ const scheduled = await reclaimIndexScope(accessProbe.access, { scopeId: target.scopeId, overrideRetention: options.overrideRetention }, deps.fetchImpl);
2351
+ if (!scheduled.ok) {
2352
+ // A refusal names the BLOCKERS the server evaluated, so an operator learns
2353
+ // that a parse is running rather than that "reclaim failed".
2354
+ const blockers = scheduled.blockers ?? [];
2355
+ const reasons = [`The index scope could not be reclaimed: ${scheduled.error}`];
2356
+ if (blockers.length > 0)
2357
+ reasons.push(` blocked by: ${blockers.join(", ")}`);
2358
+ return emitFailure(deps, options.json, reasons, {
2359
+ epic_key: options.epicKey,
2360
+ scope_id: target.scopeId,
2361
+ blockers,
2362
+ });
2363
+ }
2364
+ return emitSuccess(deps, options.json, {
2365
+ ok: true,
2366
+ epic_key: options.epicKey,
2367
+ scope_id: scheduled.value.scope_id ?? target.scopeId,
2368
+ scheduled: scheduled.value.scheduled,
2369
+ }, [
2370
+ `Scheduled reclamation of index scope ${scheduled.value.scope_id ?? target.scopeId}.`,
2371
+ " This is SCHEDULED, not done — the teardown waits out Pinecone's",
2372
+ " consistency window. Run `conduct-epic status --json` to see it reach",
2373
+ " `reclaimed`.",
2374
+ ]);
2375
+ }
2376
+ // ---------------------------------------------------------------------------
1697
2377
  // Entry point
1698
2378
  // ---------------------------------------------------------------------------
1699
2379
  /**
@@ -1728,5 +2408,11 @@ export async function runConductEpicCli(argv, overrides = {}) {
1728
2408
  return runConductEpicSpawn(deps, options);
1729
2409
  case "finish":
1730
2410
  return runConductEpicFinish(deps, options);
2411
+ case "recover":
2412
+ return runConductEpicRecover(deps, options);
2413
+ case "retire":
2414
+ return runConductEpicRetire(deps, options);
2415
+ case "reclaim":
2416
+ return runConductEpicReclaim(deps, options);
1731
2417
  }
1732
2418
  }