@bridge_gpt/mcp-server 0.2.41 → 0.2.43

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 (88) hide show
  1. package/README.md +330 -191
  2. package/build/agent-capabilities/cli.js +2 -1
  3. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  4. package/build/agents.generated.js +2 -2
  5. package/build/claude-review-workflow.js +510 -45
  6. package/build/claude-user-config-doctor.js +42 -11
  7. package/build/cli-release.js +2 -1
  8. package/build/commands.generated.js +6 -5
  9. package/build/conduct-epic/bridge-client.js +354 -113
  10. package/build/conduct-epic/checkpoint-store.js +17 -0
  11. package/build/conduct-epic/cli.js +947 -99
  12. package/build/conduct-epic/cut-protocol.js +327 -0
  13. package/build/conduct-epic/spawn.js +14 -2
  14. package/build/conductor/bridge-api-client.js +148 -1
  15. package/build/conductor/cli.js +109 -1
  16. package/build/conductor/doctor.js +101 -16
  17. package/build/conductor/epic-reconcile.js +72 -19
  18. package/build/conductor/epic-runtime.js +15 -3
  19. package/build/conductor/errors.js +47 -0
  20. package/build/conductor/git-hooks.js +205 -11
  21. package/build/conductor/install-doctor.js +230 -1
  22. package/build/conductor/local-merge.js +130 -28
  23. package/build/conductor/recovery-cli.js +313 -0
  24. package/build/conductor/recovery-operations.js +219 -0
  25. package/build/conductor/tools.js +32 -3
  26. package/build/conductor/worker-ledger-cli.js +27 -1
  27. package/build/conductor-bin.js +20 -16
  28. package/build/credentials-cli.js +3 -2
  29. package/build/docs.generated.js +2 -1
  30. package/build/doctor.js +120 -44
  31. package/build/drive-epic.js +375 -0
  32. package/build/executor/cli.js +48 -1
  33. package/build/executor/env.js +21 -0
  34. package/build/executor/http-client.js +71 -3
  35. package/build/executor/index-scope.js +39 -0
  36. package/build/executor/job-errors.js +9 -0
  37. package/build/executor/job-log-registry.js +69 -0
  38. package/build/executor/job-runner.js +198 -29
  39. package/build/executor/live-worker-registry.js +83 -0
  40. package/build/executor/observation.js +259 -6
  41. package/build/executor/platform.js +147 -3
  42. package/build/executor/process.js +58 -14
  43. package/build/executor/runner.js +454 -48
  44. package/build/executor/test-clock.js +3 -2
  45. package/build/executor/worker-finalization.js +233 -56
  46. package/build/executor/worktree.js +8 -1
  47. package/build/index-scope-contract.js +96 -0
  48. package/build/index.js +2277 -270
  49. package/build/init.js +83 -22
  50. package/build/install-bridge-conductor.js +323 -14
  51. package/build/install-bridge.js +225 -47
  52. package/build/install-doctor.js +23 -9
  53. package/build/install-reexec.js +2 -1
  54. package/build/launcher-config-inspection.js +83 -22
  55. package/build/mcp-host-config.js +331 -67
  56. package/build/mcp-host-targets.js +45 -21
  57. package/build/mcp-identity.js +92 -0
  58. package/build/mcp-install-state.js +94 -1
  59. package/build/mcp-invoke.js +2 -1
  60. package/build/mcp-provisioning.js +45 -12
  61. package/build/mcp-registration-doctor.js +35 -13
  62. package/build/mcp-server-invocation.js +4 -2
  63. package/build/merge-pull-request.js +208 -9
  64. package/build/pipelines.generated.js +305 -15
  65. package/build/plane/cli.js +73 -7
  66. package/build/plane/defaults.js +18 -5
  67. package/build/plane/manifest.js +90 -0
  68. package/build/plane/preflight.js +100 -10
  69. package/build/plane/shutdown.js +71 -3
  70. package/build/plane/test-fakes.js +9 -1
  71. package/build/readme.generated.js +1 -1
  72. package/build/regression-check.js +3 -2
  73. package/build/review-tickets.js +8 -7
  74. package/build/run-unit-tests-launcher.js +149 -6
  75. package/build/schedule-run.js +3 -2
  76. package/build/setup-epic.js +531 -82
  77. package/build/sfcc/tool-wrapper.js +15 -0
  78. package/build/start-tickets-prereqs.js +11 -6
  79. package/build/start-tickets.js +91 -85
  80. package/build/update-check.js +3 -2
  81. package/build/upgrade-advice.js +2 -1
  82. package/build/upgrade-cli.js +50 -18
  83. package/build/version.generated.js +2 -1
  84. package/build/worktree-core.js +31 -17
  85. package/docs/CONDUCTOR.md +22 -0
  86. package/docs/install/mcp-tool-integrations.md +19 -3
  87. package/package.json +2 -2
  88. package/pipelines/greenfield-setup.json +286 -0
@@ -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,15 +39,34 @@ 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 { fetchLatestVersion } from "../cli-release.js";
49
+ import { INDEX_SCOPE_CONFIGURATION_ERROR, validateOptionalIndexScope, } from "../index-scope-contract.js";
50
+ // BAPI-850: the exact-cut protocol, the scope-readiness poll bounds, and the
51
+ // local-git helpers live in ONE shared module that `setup-epic` drives too. This
52
+ // file remains the pilot's owner of the preflight and of how a cut outcome is
53
+ // reported; the cut itself is performed by the shared module.
54
+ import { createExecFileRunCommand, firstOutputLine as firstLine, lsRemoteSha, normalizeCommitSha, performExactIndexScopeCut, runGit, SCOPE_BOOTSTRAP_MAX_POLLS, SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, } from "./cut-protocol.js";
55
+ // Re-exported so existing importers of the pilot's normalizer keep compiling.
56
+ export { normalizeCommitSha };
48
57
  /** Epic and ticket keys accepted by every verb. */
49
58
  export const CONDUCT_EPIC_KEY_PATTERN = /^[A-Z]+-[0-9]+$/;
50
59
  /** The five verb families. `checkpoint set` is two tokens, one verb. */
51
- export const CONDUCT_EPIC_VERBS = ["init", "status", "checkpoint set", "finish", "spawn"];
60
+ export const CONDUCT_EPIC_VERBS = [
61
+ "init",
62
+ "status",
63
+ "checkpoint set",
64
+ "finish",
65
+ "spawn",
66
+ "recover",
67
+ "retire",
68
+ "reclaim",
69
+ ];
52
70
  /** Per-ticket fields `checkpoint set` may assign. */
53
71
  const TICKET_FIELDS = [
54
72
  "status",
@@ -67,24 +85,7 @@ const TICKET_FIELDS = [
67
85
  const TOP_LEVEL_FIELDS = ["needs_human", "counters.iterations", "counters.merges"];
68
86
  /** Build the production dependency set. */
69
87
  export function createDefaultConductEpicDeps() {
70
- const runCommand = (file, args, options) => new Promise((resolve) => {
71
- execFile(file, args, {
72
- cwd: options?.cwd,
73
- // Git porcelain output for a many-worktree checkout can be large.
74
- maxBuffer: 16 * 1024 * 1024,
75
- encoding: "utf-8",
76
- timeout: options?.timeoutMs,
77
- // Explicit: arguments are a list, never a concatenated shell string.
78
- shell: false,
79
- }, (error, stdout, stderr) => {
80
- const code = error?.code;
81
- resolve({
82
- stdout: stdout ?? "",
83
- stderr: stderr ?? "",
84
- exitCode: typeof code === "number" ? code : error ? 1 : 0,
85
- });
86
- });
87
- });
88
+ const runCommand = createExecFileRunCommand();
88
89
  const spawner = getDefaultSpawnTerminalTabForPlatform(process.platform);
89
90
  const startTicketsDeps = createDefaultStartTicketsDeps();
90
91
  return {
@@ -121,9 +122,11 @@ export function createDefaultConductEpicDeps() {
121
122
  cwd: process.cwd(),
122
123
  pid: process.pid,
123
124
  isProcessAlive: isConductEpicLockOwnerAlive,
125
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
124
126
  log: (m) => console.log(m),
125
127
  errorLog: (m) => console.error(m),
126
128
  resolveAccess: resolveConductorBridgeApiAccess,
129
+ resolveLatestPublishedVersion: () => fetchLatestVersion({ fetch: globalThis.fetch }),
127
130
  resolveRepoName: resolveRequiredStartTicketsRepoName,
128
131
  };
129
132
  }
@@ -134,12 +137,16 @@ export function createDefaultConductEpicDeps() {
134
137
  export function getConductEpicUsage() {
135
138
  return [
136
139
  "Usage:",
137
- " npx -y @bridge_gpt/mcp-server conduct-epic <verb> [flags]",
140
+ ` npx -y ${MCP_PACKAGE_NAME} conduct-epic <verb> [flags]`,
138
141
  "",
139
142
  "Verbs:",
140
143
  " init <EPIC> --tickets K1,K2,... [--base-branch <b>] [--checkpoint-path <p>] [--dry-run] [--json]",
141
- " Run the full preflight, create epic/<EPIC> on origin at the fetched base",
142
- " tip, repoint the indexed branch, write the checkpoint, and take the lock.",
144
+ " Run the full preflight, then create epic/<EPIC> on origin at the commit the",
145
+ " CANONICAL INDEX covers — not the base tip seed and verify the epic's index",
146
+ " scope at that commit, repoint the indexed branch, write the checkpoint, and",
147
+ " take the lock. --base-branch selects the base whose history is fetched and",
148
+ " recorded; the cut commit is the canonical indexed SHA and is reported",
149
+ " separately. init fails closed when the repository has no successful parse.",
143
150
  " Every preflight failure is printed in one pass; nothing is written unless",
144
151
  " all of them pass. --dry-run prints the validated plan and writes nothing.",
145
152
  "",
@@ -148,14 +155,17 @@ export function getConductEpicUsage() {
148
155
  " CI, review, parse, deadline, and lock state. --json is required. A missing",
149
156
  " checkpoint exits 0 with checkpoint_exists:false. A failed probe leaves its",
150
157
  " sub-object null and is listed in probe_errors; it never fails the command.",
158
+ " `scopes` lists EVERY index scope this repository owns — expired and",
159
+ " reclaiming ones included — so a crashed epic is visible without SQL.",
151
160
  "",
152
161
  " checkpoint set <EPIC> --ticket <KEY> [--field <name> <value>]... [--journal <line>]",
153
162
  " [--checkpoint-path <p>]",
154
163
  " Apply ABSOLUTE field values (the caller computes n+1 from status).",
155
164
  ` Ticket fields: ${TICKET_FIELDS.join(", ")}.`,
156
165
  ` Top-level fields: ${TOP_LEVEL_FIELDS.join(", ")}.`,
157
- " Repeat --field to write several in ONE atomic mutation — parse_requested_at",
158
- " and parse_requested_for_sha are recorded together, never in two writes.",
166
+ " Repeat --field to write several in ONE atomic mutation.",
167
+ " parse_requested_at / parse_requested_for_sha are ACCEPTED for older",
168
+ " checkpoints but no longer written: freshness is read from the scope.",
159
169
  "",
160
170
  " finish <EPIC> [--checkpoint-path <p>] [--json]",
161
171
  " Restore the server's indexed base branch (idempotent), release the owned",
@@ -167,6 +177,26 @@ export function getConductEpicUsage() {
167
177
  " file's contents, then increment counters.sessions_spawned and append a",
168
178
  " journal line. Respawn and conflict budgets are the CALLER's job.",
169
179
  "",
180
+ " recover <EPIC> [--scope <id>] [--checkpoint-path <p>] [--json]",
181
+ " Take a NEW ownership generation for a crashed epic's index scope and",
182
+ " record the returned fencing epoch locally. Use this instead of SQL when",
183
+ " `status` shows a scope whose lease expired. Defaults to the epic's own",
184
+ " scope; --scope targets another one (e.g. when the checkpoint is gone).",
185
+ "",
186
+ " retire <EPIC> [--scope <id>] [--checkpoint-path <p>] [--json]",
187
+ " Start the scope's retention clock. Deletes NOTHING — the scope stays",
188
+ " readable for post-mortem for the whole retention window. Idempotent.",
189
+ " `finish` does this for you; this verb is for retiring without finishing.",
190
+ "",
191
+ " reclaim <EPIC> [--scope <id>] [--override-retention] [--checkpoint-path <p>] [--json]",
192
+ " Ask the server to schedule the scope's teardown: three Pinecone",
193
+ " namespaces, six parse-table slices, three config rows, and a retained",
194
+ " tombstone. Returns as soon as it is SCHEDULED; watch `status` for the",
195
+ " result. --override-retention waives only the still-valid-lease and",
196
+ " unelapsed-retention waits — an active parse, a held parse lock, a live",
197
+ " automation run, or a live epic run still refuse. There is no raw",
198
+ " deletion mode.",
199
+ "",
170
200
  "Common:",
171
201
  " -h, --help Show this help",
172
202
  "",
@@ -189,6 +219,9 @@ const VERB_FLAGS = {
189
219
  "checkpoint-set": ["--ticket", "--field", "--journal", "--checkpoint-path"],
190
220
  finish: ["--checkpoint-path", "--json"],
191
221
  spawn: ["--ticket", "--prompt-file", "--agent", "--checkpoint-path", "--json"],
222
+ recover: ["--scope", "--checkpoint-path", "--json"],
223
+ retire: ["--scope", "--checkpoint-path", "--json"],
224
+ reclaim: ["--scope", "--override-retention", "--checkpoint-path", "--json"],
192
225
  };
193
226
  /**
194
227
  * Parse and fully validate argv BEFORE any I/O.
@@ -214,7 +247,13 @@ export function parseConductEpicArgs(argv) {
214
247
  verb = "checkpoint-set";
215
248
  rest = argv.slice(2);
216
249
  }
217
- else if (argv[0] === "init" || argv[0] === "status" || argv[0] === "finish" || argv[0] === "spawn") {
250
+ else if (argv[0] === "init" ||
251
+ argv[0] === "status" ||
252
+ argv[0] === "finish" ||
253
+ argv[0] === "spawn" ||
254
+ argv[0] === "recover" ||
255
+ argv[0] === "retire" ||
256
+ argv[0] === "reclaim") {
218
257
  verb = argv[0];
219
258
  rest = argv.slice(1);
220
259
  }
@@ -229,6 +268,7 @@ export function parseConductEpicArgs(argv) {
229
268
  fields: [],
230
269
  dryRun: false,
231
270
  json: false,
271
+ overrideRetention: false,
232
272
  };
233
273
  const seen = new Set();
234
274
  let epicKey;
@@ -257,6 +297,9 @@ export function parseConductEpicArgs(argv) {
257
297
  case "--json":
258
298
  options.json = true;
259
299
  break;
300
+ case "--override-retention":
301
+ options.overrideRetention = true;
302
+ break;
260
303
  case "--field": {
261
304
  const name = rest[i + 1];
262
305
  const value = rest[i + 2];
@@ -346,6 +389,16 @@ function assignFlagValue(options, flag, value) {
346
389
  case "--journal":
347
390
  options.journal = value;
348
391
  return null;
392
+ case "--scope": {
393
+ // Shape-validated here, before any I/O: a server-minted scope id is a uuid4
394
+ // hex. Refusing a malformed value at the boundary means a typo never becomes
395
+ // an authenticated request naming something arbitrary.
396
+ if (!/^[0-9a-f]{32}$/.test(value)) {
397
+ return `Invalid --scope value '${value}'. Expected a 32-character index-scope id.`;
398
+ }
399
+ options.scope = value;
400
+ return null;
401
+ }
349
402
  default:
350
403
  return `Unknown flag '${flag}'.`;
351
404
  }
@@ -490,20 +543,16 @@ async function resolveAccess(deps) {
490
543
  }
491
544
  /** Run `git` with list args in the repository working directory. */
492
545
  function git(deps, args) {
493
- return Promise.resolve(deps.runCommand("git", args, { cwd: deps.cwd }));
494
- }
495
- /** The single trimmed line a `git rev-parse`-style command produced, or null. */
496
- function firstLine(result) {
497
- const value = result.stdout.split("\n")[0]?.trim() ?? "";
498
- return value.length === 0 ? null : value;
546
+ return runGit(cutProtocolDeps(deps), args);
499
547
  }
500
- /** The SHA from `git ls-remote --heads origin <ref>` output, or null. */
501
- function lsRemoteSha(result) {
502
- const line = firstLine(result);
503
- if (line === null)
504
- return null;
505
- const sha = line.split(/\s+/)[0]?.trim() ?? "";
506
- return sha.length === 0 ? null : sha;
548
+ /** The strict subset of the pilot's deps the shared cut protocol consumes. */
549
+ function cutProtocolDeps(deps) {
550
+ return {
551
+ runCommand: deps.runCommand,
552
+ cwd: deps.cwd,
553
+ fetchImpl: deps.fetchImpl,
554
+ errorLog: deps.errorLog,
555
+ };
507
556
  }
508
557
  function isRecord(value) {
509
558
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -521,6 +570,182 @@ function elapsedSeconds(from, now) {
521
570
  function inFlightTicket(checkpoint) {
522
571
  return checkpoint.tickets.find((ticket) => ticket.status !== "done") ?? null;
523
572
  }
573
+ // ---------------------------------------------------------------------------
574
+ // The published-build identity (BAPI-873)
575
+ // ---------------------------------------------------------------------------
576
+ /**
577
+ * Hard bound on launching the published package to read its identity. `npx` may
578
+ * have to download a tarball on a cold cache, so this is generous relative to
579
+ * the registry lookup — but it is a bound, because a wedged launch must never
580
+ * stall `init` indefinitely.
581
+ */
582
+ export const PUBLISHED_IDENTITY_TIMEOUT_MS = 120_000;
583
+ /** The identity shape `--version` emits: 12 lowercase hex, optionally `-dirty`. */
584
+ const PUBLISHED_IDENTITY_PATTERN = /^commit: ([0-9a-f]{12})(-dirty)?$/;
585
+ /** The sentinel a build with no git metadata reports. */
586
+ const PUBLISHED_IDENTITY_UNKNOWN = "unknown";
587
+ /** Human wording for each unavailable category, for the fail-open advisory. */
588
+ export function describePublishedIdentityReason(reason) {
589
+ switch (reason) {
590
+ case "registry_unreadable":
591
+ return "the npm registry could not be read";
592
+ case "launch_failed":
593
+ return "the published package could not be launched";
594
+ case "unreadable_output":
595
+ return "the published package reported no readable build identity";
596
+ case "identity_unknown":
597
+ return "the published build reports an unknown build commit";
598
+ }
599
+ }
600
+ /**
601
+ * Read the commit identity embedded in the LATEST PUBLISHED package.
602
+ *
603
+ * Two bounded steps: resolve the exact latest version through the shared
604
+ * registry lookup, then run THAT EXACT VERSION with `--version`. The exactness
605
+ * matters — invoking a moving `@latest` would read whatever the registry served
606
+ * at that instant, so the version reported and the version inspected could
607
+ * differ, and the gate would be comparing an identity to the wrong build.
608
+ *
609
+ * Every failure is `unavailable`, never a mismatch: not knowing what was
610
+ * published is a different fact from knowing it is wrong, and only the second
611
+ * may block a run.
612
+ */
613
+ export async function readPublishedBuildIdentity(deps) {
614
+ const resolveVersion = deps.resolveLatestPublishedVersion ?? (() => fetchLatestVersion({ fetch: deps.fetchImpl }));
615
+ let version;
616
+ try {
617
+ version = await resolveVersion();
618
+ }
619
+ catch {
620
+ return { kind: "unavailable", reason: "registry_unreadable" };
621
+ }
622
+ if (typeof version !== "string" || version.trim().length === 0) {
623
+ return { kind: "unavailable", reason: "registry_unreadable" };
624
+ }
625
+ const resolvedVersion = version.trim();
626
+ let probe;
627
+ try {
628
+ probe = await deps.runCommand("npx", ["-y", `${MCP_PACKAGE_NAME}@${resolvedVersion}`, "--version"], { cwd: deps.cwd, timeoutMs: PUBLISHED_IDENTITY_TIMEOUT_MS });
629
+ }
630
+ catch {
631
+ return { kind: "unavailable", reason: "launch_failed" };
632
+ }
633
+ if (!probe || probe.exitCode !== 0) {
634
+ return { kind: "unavailable", reason: "launch_failed" };
635
+ }
636
+ const lines = String(probe.stdout ?? "")
637
+ .split("\n")
638
+ .map((line) => line.trim())
639
+ .filter((line) => line.length > 0);
640
+ // The first line is the semver contract `--version` has always emitted. It
641
+ // must be the version we asked for, or the output does not describe the build
642
+ // this reader resolved.
643
+ if (lines[0] !== resolvedVersion) {
644
+ return { kind: "unavailable", reason: "unreadable_output" };
645
+ }
646
+ const commitLine = lines.slice(1).find((line) => line.startsWith("commit:"));
647
+ if (commitLine === undefined) {
648
+ return { kind: "unavailable", reason: "unreadable_output" };
649
+ }
650
+ if (commitLine === `commit: ${PUBLISHED_IDENTITY_UNKNOWN}`) {
651
+ return { kind: "unavailable", reason: "identity_unknown" };
652
+ }
653
+ const match = PUBLISHED_IDENTITY_PATTERN.exec(commitLine);
654
+ if (match === null) {
655
+ return { kind: "unavailable", reason: "unreadable_output" };
656
+ }
657
+ return { kind: "known", version: resolvedVersion, commit: match[1], dirty: match[2] !== undefined };
658
+ }
659
+ /**
660
+ * Decide whether the PUBLISHED build carries the code this epic will be cut at.
661
+ *
662
+ * The gate is expressed as CONTAINMENT, not as a version floor and not as an
663
+ * exact-commit match. "The published build is at least as new as the commit we
664
+ * are conducting" is the property that actually matters, and it is the property
665
+ * a version number could never express: the same semver spanned three different
666
+ * contents, which is why the old floor was unverifiable.
667
+ *
668
+ * Blocking and fail-open are separated deliberately. Knowing the published build
669
+ * is wrong blocks. NOT knowing what was published — a registry outage, a cold
670
+ * npx launch that failed, a build with no git metadata — is an advisory, because
671
+ * a network problem must never stop a run.
672
+ */
673
+ export async function evaluatePublishGate(deps, expectedCommitSha) {
674
+ if (expectedCommitSha === null) {
675
+ // The canonical-index check already recorded its own failure; adding a
676
+ // second one for the same root cause only pads the report.
677
+ return {
678
+ failures: [],
679
+ advisories: [
680
+ "advisory: the publish gate was not evaluated because no canonical indexed commit is available to check against.",
681
+ ],
682
+ };
683
+ }
684
+ const expected = normalizeCommitSha(expectedCommitSha);
685
+ if (expected === null) {
686
+ return {
687
+ failures: [
688
+ "The publish gate cannot be evaluated: the expected commit is not a full 40-character SHA. " +
689
+ "Refusing rather than comparing an arbitrary prefix.",
690
+ ],
691
+ advisories: [],
692
+ };
693
+ }
694
+ const identity = await readPublishedBuildIdentity(deps);
695
+ if (identity.kind === "unavailable") {
696
+ return {
697
+ failures: [],
698
+ advisories: [
699
+ `advisory: the publish gate could not be verified — ${describePublishedIdentityReason(identity.reason)}. ` +
700
+ `Initialization is continuing; the published ${MCP_PACKAGE_NAME} build was NOT confirmed to contain ${expected}.`,
701
+ ],
702
+ };
703
+ }
704
+ if (identity.dirty) {
705
+ return {
706
+ failures: [
707
+ `The published ${MCP_PACKAGE_NAME}@${identity.version} reports build commit ${identity.commit}-dirty. ` +
708
+ "A dirty build carries content that no commit represents, so it cannot be verified to contain " +
709
+ `${expected}. Publish a build from a clean checkout.`,
710
+ ],
711
+ advisories: [],
712
+ };
713
+ }
714
+ // The published SHA must be an object THIS checkout knows about before any
715
+ // ancestry claim is possible. It is a short SHA, so it cannot be fetched by
716
+ // name — an unresolvable one is "cannot verify", never "wrong".
717
+ const present = await git(deps, ["rev-parse", "--verify", "--quiet", `${identity.commit}^{commit}`]);
718
+ if (present.exitCode !== 0) {
719
+ return {
720
+ failures: [],
721
+ advisories: [
722
+ `advisory: the published ${MCP_PACKAGE_NAME}@${identity.version} build commit ${identity.commit} ` +
723
+ "is not present in this checkout, so the publish gate could not be verified. " +
724
+ `Initialization is continuing; fetch origin and confirm that build contains ${expected}.`,
725
+ ],
726
+ };
727
+ }
728
+ const contains = await git(deps, ["merge-base", "--is-ancestor", expected, identity.commit]);
729
+ if (contains.exitCode === 0)
730
+ return { failures: [], advisories: [] };
731
+ if (contains.exitCode === 1) {
732
+ return {
733
+ failures: [
734
+ `The published ${MCP_PACKAGE_NAME}@${identity.version} was built from ${identity.commit}, ` +
735
+ `which does not contain ${expected} — the canonical indexed commit this epic is cut at. ` +
736
+ "Publish a build containing that commit before initializing.",
737
+ ],
738
+ advisories: [],
739
+ };
740
+ }
741
+ return {
742
+ failures: [],
743
+ advisories: [
744
+ `advisory: the publish gate could not be verified — the ancestry of published build commit ` +
745
+ `${identity.commit} could not be determined locally. Initialization is continuing.`,
746
+ ],
747
+ };
748
+ }
524
749
  /**
525
750
  * Run every independent `init` check and ACCUMULATE the failures.
526
751
  *
@@ -541,6 +766,7 @@ function inFlightTicket(checkpoint) {
541
766
  export async function collectConductEpicInitPreflight(deps, options) {
542
767
  const failures = [];
543
768
  const announcements = [];
769
+ const advisories = [];
544
770
  const epicBranch = epicBranchFor(options.epicKey);
545
771
  let pendingSupervisorConfig = null;
546
772
  // (1) gh authentication.
@@ -570,7 +796,8 @@ export async function collectConductEpicInitPreflight(deps, options) {
570
796
  failures.push(accessResult.error);
571
797
  let baseBranch = options.baseBranch ?? null;
572
798
  let baseSha = null;
573
- let epicBranchAlreadyAtBase = false;
799
+ let cutCommitSha = null;
800
+ let epicBranchAlreadyAtCut = false;
574
801
  if (access !== null) {
575
802
  // (4) auto_merge_enabled, and (5) a non-vacuous required-check set.
576
803
  const readiness = await getConductorReadiness(access, deps.fetchImpl);
@@ -636,22 +863,34 @@ export async function collectConductEpicInitPreflight(deps, options) {
636
863
  else if (runState.status !== 404) {
637
864
  failures.push(`The epic-run state for ${options.epicKey} could not be read: ${runState.error}`);
638
865
  }
639
- // (10) Index-branch override: absent, or this epic's own (a re-init after a
640
- // crash). A foreign override is named so the operator knows which epic still
641
- // holds the repository's index.
642
- const indexBranch = await getIndexBranch(access, deps.fetchImpl);
643
- if (!indexBranch.ok) {
644
- failures.push(`The indexed-branch state could not be read: ${indexBranch.error}`);
866
+ // (10) BAPI-847: there is no repository-wide index-branch override to check
867
+ // any more. An epic no longer takes the repository's index away from anyone —
868
+ // it gets its OWN index scope — so two epics running at once is an ordinary
869
+ // state rather than a conflict a preflight has to detect. The default-base
870
+ // resolution below therefore never reads a stored "original" branch: nothing
871
+ // was ever repointed, so the repository's configured base IS the original.
872
+ // (11) The canonical index must have a usable commit (BAPI-843). This is the
873
+ // check that inverts the cut order: without a `succeeded` canonical parse
874
+ // publishing a commit, there is no commit to cut at, and cutting at the base
875
+ // tip instead is exactly the behavior this replaces. The failure text names
876
+ // the fix an operator can actually perform.
877
+ const parseStatus = await getParseStatus(access, deps.fetchImpl);
878
+ if (!parseStatus.ok) {
879
+ failures.push(`The canonical parse status could not be read: ${parseStatus.error}`);
880
+ }
881
+ else if (parseStatus.value.status !== "succeeded") {
882
+ failures.push(`The canonical index for ${access.repoName} has no successful parse ` +
883
+ `(status: ${String(parseStatus.value.status)}). Parse the repository first.`);
645
884
  }
646
885
  else {
647
- const override = indexBranch.value.override;
648
- if (override !== null && override.override_branch !== epicBranch) {
649
- failures.push(`The repository index is already repointed to '${override.override_branch}' by another epic. ` +
650
- "Run `conduct-epic finish` for that epic first.");
886
+ const indexed = normalizeCommitSha(parseStatus.value.indexed_commit_sha);
887
+ if (indexed === null) {
888
+ failures.push(`The canonical index for ${access.repoName} published no commit for its ` +
889
+ "last successful parse, so there is no commit to cut at. " +
890
+ "Parse the repository first.");
651
891
  }
652
- else if (override !== null && baseBranch === null) {
653
- // (8) default base: this epic's own override remembers the real original.
654
- baseBranch = override.original_base_branch;
892
+ else {
893
+ cutCommitSha = indexed;
655
894
  }
656
895
  }
657
896
  // (8) default base, continued: the configured base branch, then `main`.
@@ -669,8 +908,9 @@ export async function collectConductEpicInitPreflight(deps, options) {
669
908
  failures.push(`The resolved base branch is unusable: ${branchReason}`);
670
909
  }
671
910
  else {
672
- // (8) The base must exist on origin AFTER a fetch an epic branch cut from a
673
- // stale local ref silently starts the epic behind main.
911
+ // (8) The base is still fetched local git needs its object history, and the
912
+ // cut commit is almost always reachable from it — but the base tip is NO
913
+ // LONGER the branch source (BAPI-843). It is reported for context only.
674
914
  const fetched = await git(deps, ["fetch", "origin", baseBranch]);
675
915
  if (fetched.exitCode !== 0) {
676
916
  failures.push(`git fetch origin ${baseBranch} failed.`);
@@ -680,7 +920,38 @@ export async function collectConductEpicInitPreflight(deps, options) {
680
920
  if (baseSha === null) {
681
921
  failures.push(`origin/${baseBranch} does not exist after fetching.`);
682
922
  }
683
- // (9) `epic/<EPIC>` must be absent on origin, or already at the base tip.
923
+ if (cutCommitSha !== null) {
924
+ // (12) The EXACT cut object must be resolvable locally, because `init`
925
+ // pushes it by SHA. The base fetch usually brings it along; when it did
926
+ // not — the index covers a commit that is no longer an ancestor of the
927
+ // base tip — one targeted, NON-MUTATING fetch of that SHA is attempted (it
928
+ // updates no ref, creates no branch, and checks nothing out). Failing here
929
+ // is deliberate: it happens before the cut protocol, so nothing has been
930
+ // held, pushed, or recorded.
931
+ const present = await git(deps, ["rev-parse", "--verify", "--quiet", `${cutCommitSha}^{commit}`]);
932
+ if (present.exitCode !== 0) {
933
+ await git(deps, ["fetch", "origin", cutCommitSha]);
934
+ const retry = await git(deps, ["rev-parse", "--verify", "--quiet", `${cutCommitSha}^{commit}`]);
935
+ if (retry.exitCode !== 0) {
936
+ failures.push(`The canonical indexed commit ${cutCommitSha} could not be resolved locally ` +
937
+ `even after fetching it from origin. Fetch it manually, or re-parse ${access?.repoName ?? "the repository"}.`);
938
+ cutCommitSha = null;
939
+ }
940
+ }
941
+ }
942
+ // (13) BAPI-873: the publish gate. Evaluated HERE — after the cut commit is
943
+ // known and proven present locally, and still before anything mutates —
944
+ // because the gate's question is whether the PUBLISHED package (the one
945
+ // `start-tickets` spawns for every worker) already carries the code this
946
+ // epic is cut at. A readable mismatch joins `failures` so it is reported
947
+ // alongside every other readiness problem; an unreadable published identity
948
+ // becomes an advisory and the run continues.
949
+ const publishGate = await evaluatePublishGate(deps, cutCommitSha);
950
+ failures.push(...publishGate.failures);
951
+ advisories.push(...publishGate.advisories);
952
+ // (9) `epic/<EPIC>` must be absent on origin, or already at exactly the
953
+ // canonical indexed commit. An epic branch sitting at ANY other commit still
954
+ // fails closed — including the base tip, which is no longer special.
684
955
  const existing = await git(deps, ["ls-remote", "--heads", "origin", `refs/heads/${epicBranch}`]);
685
956
  if (existing.exitCode !== 0) {
686
957
  failures.push(`git ls-remote could not read origin/${epicBranch}.`);
@@ -688,11 +959,12 @@ export async function collectConductEpicInitPreflight(deps, options) {
688
959
  else {
689
960
  const existingSha = lsRemoteSha(existing);
690
961
  if (existingSha !== null) {
691
- if (baseSha !== null && existingSha === baseSha) {
692
- epicBranchAlreadyAtBase = true;
962
+ if (cutCommitSha !== null && existingSha === cutCommitSha) {
963
+ epicBranchAlreadyAtCut = true;
693
964
  }
694
965
  else {
695
- failures.push(`origin/${epicBranch} already exists at a commit other than the ${baseBranch} tip. ` +
966
+ failures.push(`origin/${epicBranch} already exists at ${existingSha}, which is not the ` +
967
+ `canonical indexed commit${cutCommitSha ? ` ${cutCommitSha}` : ""}. ` +
696
968
  "Delete it or finish the previous run before re-initializing.");
697
969
  }
698
970
  }
@@ -714,10 +986,12 @@ export async function collectConductEpicInitPreflight(deps, options) {
714
986
  return {
715
987
  failures,
716
988
  announcements,
989
+ advisories,
717
990
  access,
718
991
  baseBranch,
719
992
  baseSha,
720
- epicBranchAlreadyAtBase,
993
+ cutCommitSha,
994
+ epicBranchAlreadyAtCut,
721
995
  pendingSupervisorConfig,
722
996
  };
723
997
  }
@@ -743,6 +1017,80 @@ function requiredCheckNamesFromResolve(value) {
743
1017
  }
744
1018
  return names;
745
1019
  }
1020
+ /**
1021
+ * Drive the scope from a recorded cut to `ready`, or report why it did not.
1022
+ *
1023
+ * Schedules the bootstrap (seed + verifying parse) and then POLLS the control
1024
+ * plane, because readiness is a server-side fact this CLI observes rather than
1025
+ * one it can conclude from its own request succeeding.
1026
+ *
1027
+ * Each terminal state maps to its own operator instruction, and the distinction
1028
+ * matters: `canonical_index_advanced` means re-run `init` (the cut protocol
1029
+ * re-drives at the newer commit), while a verification failure means the seed
1030
+ * itself is wrong and re-cutting would just reproduce it.
1031
+ */
1032
+ async function driveIndexScopeBootstrap(deps, access, scopeId, options) {
1033
+ const scheduled = await bootstrapIndexScope(access, { scopeId }, deps.fetchImpl);
1034
+ if (!scheduled.ok) {
1035
+ return { ok: false, failures: [`The index scope could not be seeded: ${scheduled.error}`] };
1036
+ }
1037
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1038
+ let lastState = "unknown";
1039
+ for (let poll = 0; poll < SCOPE_BOOTSTRAP_MAX_POLLS; poll += 1) {
1040
+ await sleep(SCOPE_BOOTSTRAP_POLL_INTERVAL_MS);
1041
+ const status = await getIndexScopeStatus(access, scopeId, deps.fetchImpl);
1042
+ if (!status.ok) {
1043
+ // A transient read failure is not a verdict: keep polling and let the
1044
+ // bound below be the thing that gives up.
1045
+ lastState = `unreadable (${status.error})`;
1046
+ continue;
1047
+ }
1048
+ lastState = status.value.lifecycle_state;
1049
+ if (status.value.lifecycle_state === "ready") {
1050
+ if (status.value.indexed_commit_sha !== null &&
1051
+ status.value.indexed_commit_sha === status.value.cut_commit_sha) {
1052
+ return { ok: true, failures: [] };
1053
+ }
1054
+ // `ready` is the server's verdict, and the server only promotes a scope
1055
+ // whose watermark matches. Disagreeing here would mean the control plane
1056
+ // contradicted itself, which is worth refusing rather than proceeding.
1057
+ return {
1058
+ ok: false,
1059
+ failures: [
1060
+ `Index scope ${scopeId} reports ready but its indexed commit ` +
1061
+ `(${status.value.indexed_commit_sha ?? "none"}) is not the cut commit ` +
1062
+ `(${status.value.cut_commit_sha ?? "none"}).`,
1063
+ ],
1064
+ };
1065
+ }
1066
+ if (status.value.lifecycle_state === "failed") {
1067
+ const reason = status.value.last_error ?? "unknown";
1068
+ if (reason === "canonical_index_advanced") {
1069
+ return {
1070
+ ok: false,
1071
+ failures: [
1072
+ `The canonical index advanced before the seed could run, so the scope was not seeded. ` +
1073
+ `Delete origin/${epicBranchFor(options.epicKey)} and re-run init to cut at the newer commit.`,
1074
+ ],
1075
+ };
1076
+ }
1077
+ return {
1078
+ ok: false,
1079
+ failures: [
1080
+ `Index scope ${scopeId} failed verification (${reason}). ` +
1081
+ "The epic branch and its recorded cut are intact; re-run init to re-drive verification.",
1082
+ ],
1083
+ };
1084
+ }
1085
+ }
1086
+ return {
1087
+ ok: false,
1088
+ failures: [
1089
+ `Index scope ${scopeId} did not become ready within the bootstrap window ` +
1090
+ `(last observed state: ${lastState}). Re-run init to resume verification.`,
1091
+ ],
1092
+ };
1093
+ }
746
1094
  /**
747
1095
  * `conduct-epic init` — the only verb that provisions.
748
1096
  *
@@ -767,6 +1115,11 @@ export async function runConductEpicInit(deps, options) {
767
1115
  ], { epic_key: options.epicKey, checkpoint_path: checkpointPath });
768
1116
  }
769
1117
  const preflight = await collectConductEpicInitPreflight(deps, options);
1118
+ // BAPI-873: fail-open advisories are reported before the outcome is decided,
1119
+ // on stderr, on both paths — an advisory that only printed on failure would
1120
+ // let a run proceed silently past an unverified publish gate.
1121
+ for (const line of preflight.advisories)
1122
+ deps.errorLog(line);
770
1123
  if (preflight.failures.length > 0) {
771
1124
  for (const line of preflight.announcements)
772
1125
  deps.errorLog(line);
@@ -776,13 +1129,19 @@ export async function runConductEpicInit(deps, options) {
776
1129
  });
777
1130
  }
778
1131
  const access = preflight.access;
779
- if (access === null || preflight.baseBranch === null || preflight.baseSha === null) {
1132
+ if (access === null ||
1133
+ preflight.baseBranch === null ||
1134
+ preflight.baseSha === null ||
1135
+ preflight.cutCommitSha === null) {
780
1136
  // Unreachable: any of these being absent records a failure above. Guarded so
781
- // a future edit cannot turn a missing precondition into a push.
1137
+ // a future edit cannot turn a missing precondition into a push — in
1138
+ // particular a missing `cutCommitSha`, which would otherwise be a push at
1139
+ // `undefined`.
782
1140
  return emitFailure(deps, options.json, ["init preflight completed without a usable plan."], {
783
1141
  epic_key: options.epicKey,
784
1142
  });
785
1143
  }
1144
+ const cutCommitSha = preflight.cutCommitSha;
786
1145
  // Starts as the preflight's own lines (which in `--dry-run` already include the
787
1146
  // would-enable notice) and grows by at most the one success line below.
788
1147
  const announcements = [...preflight.announcements];
@@ -791,8 +1150,12 @@ export async function runConductEpicInit(deps, options) {
791
1150
  const describePlan = () => [
792
1151
  `epic: ${options.epicKey}`,
793
1152
  `repo: ${access.repoName}`,
1153
+ // The base branch and the cut commit are reported SEPARATELY and never
1154
+ // conflated: the base is context (and the object history git needs), the cut
1155
+ // is the commit the epic actually starts from.
794
1156
  `base: ${preflight.baseBranch} @ ${preflight.baseSha}`,
795
- `branch: ${epicBranch}${preflight.epicBranchAlreadyAtBase ? " (already at the base tip)" : ""}`,
1157
+ `cut: ${cutCommitSha} (canonical indexed commit)`,
1158
+ `branch: ${epicBranch}${preflight.epicBranchAlreadyAtCut ? " (already at the cut commit)" : ""}`,
796
1159
  `tickets: ${options.tickets.join(", ")}`,
797
1160
  `checkpoint: ${checkpointPath}`,
798
1161
  ...announcements,
@@ -805,10 +1168,11 @@ export async function runConductEpicInit(deps, options) {
805
1168
  epic_branch: epicBranch,
806
1169
  base_branch: preflight.baseBranch,
807
1170
  base_sha: preflight.baseSha,
1171
+ cut_commit_sha: cutCommitSha,
808
1172
  tickets: options.tickets,
809
1173
  checkpoint_path: checkpointPath,
810
1174
  announcements,
811
- }, ["Planned (dry run — nothing was pushed, repointed, or written):", ...describePlan()]);
1175
+ }, ["Planned (dry run — nothing was pushed, cut, seeded, repointed, or written):", ...describePlan()]);
812
1176
  }
813
1177
  // The FIRST durable mutation of the whole verb, deliberately placed here: every
814
1178
  // preflight check has passed, and nothing has been pushed, repointed, written,
@@ -824,31 +1188,60 @@ export async function runConductEpicInit(deps, options) {
824
1188
  }
825
1189
  announcements.push(`announced: auto_merge_enabled was OFF and has been enabled on the ${access.repoName} project defaults.`);
826
1190
  }
827
- // Push the epic branch WITHOUT a local checkout: the remote ref is created
828
- // directly from the fetched remote-tracking ref, so no local branch, worktree,
829
- // or checked-out state is touched.
830
- const pushed = await git(deps, [
831
- "push",
832
- "origin",
833
- `refs/remotes/origin/${preflight.baseBranch}:refs/heads/${epicBranch}`,
834
- ]);
835
- if (pushed.exitCode !== 0) {
836
- return emitFailure(deps, options.json, [`Could not create origin/${epicBranch} from the ${preflight.baseBranch} tip.`], {
837
- epic_key: options.epicKey,
838
- });
1191
+ // --- The cut protocol (BAPI-843) ----------------------------------------
1192
+ //
1193
+ // Driven by the SHARED exact-cut module (BAPI-850): `cut/begin`, the re-check
1194
+ // of `origin/epic/<EPIC>` under the hold, the exact-SHA push with the
1195
+ // operator's own git, the read-back, `cut/commit`, and the release on every
1196
+ // outcome all happen in `performExactIndexScopeCut`, while the SERVER holds
1197
+ // the canonical repository's parse lock so the commit the index covers cannot
1198
+ // move underneath the ref being created. The pilot creates no `epic_run`, so
1199
+ // it passes no run association.
1200
+ const cutOutcome = await performExactIndexScopeCut(cutProtocolDeps(deps), access, {
1201
+ featureBranch: epicBranch,
1202
+ baseBranch: preflight.baseBranch,
1203
+ candidateCommitSha: cutCommitSha,
1204
+ });
1205
+ if (!cutOutcome.ok) {
1206
+ // `begin` refusals carry the checkpoint path (nothing was leased); every
1207
+ // later refusal names only the epic, exactly as before the extraction.
1208
+ return emitFailure(deps, options.json, cutOutcome.failures, cutOutcome.kind === "begin_refused"
1209
+ ? { epic_key: options.epicKey, checkpoint_path: checkpointPath }
1210
+ : { epic_key: options.epicKey });
839
1211
  }
840
- const repointed = await repointIndexBranch(access, { branch: epicBranch }, deps.fetchImpl);
841
- if (!repointed.ok) {
842
- return emitFailure(deps, options.json, [`The repository index could not be repointed to ${epicBranch}: ${repointed.error}`], {
1212
+ // A successful outcome IS the recorded cut: the shared module returns `ok`
1213
+ // only after `cut/commit` accepted the ref, so there is no unrecorded-cut
1214
+ // state to guard against here.
1215
+ const cut = cutOutcome.lease;
1216
+ // --- Seed + verify, only after the hold is released ----------------------
1217
+ const scopeReady = await driveIndexScopeBootstrap(deps, access, cut.scope_id, options);
1218
+ if (!scopeReady.ok) {
1219
+ return emitFailure(deps, options.json, scopeReady.failures, {
843
1220
  epic_key: options.epicKey,
1221
+ scope_id: cut.scope_id,
1222
+ checkpoint_path: checkpointPath,
844
1223
  });
845
1224
  }
1225
+ announcements.push(`announced: index scope ${cut.scope_id} is ready at ${cut.cut_commit_sha}.`);
1226
+ // BAPI-847: `init` used to repoint the REPOSITORY's `base_branch` at the epic
1227
+ // branch here, which is the defect this epic exists to remove — a
1228
+ // repository-wide mutation that every unrelated run then resolved through. The
1229
+ // scope provisioned above already carries the epic branch on its own shadow
1230
+ // config row, so the canonical repository's configuration is never touched and
1231
+ // nothing has to be restored later.
1232
+ //
1233
+ // The checkpoint is still written LAST: it is only correct once the scope has
1234
+ // proven its own coverage.
846
1235
  const request = lockRequest(deps);
847
1236
  const checkpoint = createInitialConductEpicCheckpoint({
848
1237
  epicKey: options.epicKey,
849
1238
  repoName: access.repoName,
850
1239
  epicBranch,
851
1240
  baseBranchOriginal: preflight.baseBranch,
1241
+ // BAPI-844: the server-minted scope from THIS init's cut, recorded so every
1242
+ // later `spawn` declares the scope the epic was actually cut against. It is
1243
+ // stored exactly as returned; nothing local mints, edits, or defaults it.
1244
+ indexScopeId: cut.scope_id,
852
1245
  ticketKeys: options.tickets,
853
1246
  now: deps.now().toISOString(),
854
1247
  lock: { owner_pid: request.ownerPid, host: request.host, acquired_at: request.acquiredAt },
@@ -874,10 +1267,11 @@ export async function runConductEpicInit(deps, options) {
874
1267
  epic_branch: epicBranch,
875
1268
  base_branch: preflight.baseBranch,
876
1269
  base_sha: preflight.baseSha,
1270
+ cut_commit_sha: cutCommitSha,
1271
+ scope_id: cut.scope_id,
877
1272
  tickets: options.tickets,
878
1273
  checkpoint_path: checkpointPath,
879
1274
  lock_path: resolveConductEpicLockPath(checkpointPath),
880
- index_repointed: true,
881
1275
  announcements,
882
1276
  }, ["Initialized:", ...describePlan()]);
883
1277
  }
@@ -1058,6 +1452,102 @@ export async function runConductEpicStatus(deps, options) {
1058
1452
  parse = normalizeParseStatus(parseStatus.value);
1059
1453
  }
1060
1454
  }
1455
+ // --- scope (BAPI-845) ----------------------------------------------------
1456
+ // The pilot's freshness evidence, asked DIRECTLY. `parse` above is
1457
+ // repository-level and says nothing about whether *this* merge was indexed,
1458
+ // which is why Row 5 used to reconstruct causality from a request timestamp and
1459
+ // a head SHA. This probe replaces that inference with the scope's own answer:
1460
+ // its lifecycle, both watermarks, and a bounded freshness verdict.
1461
+ //
1462
+ // Absent only when the epic declares no scope — a pilot epic cut before
1463
+ // BAPI-843, or a non-scope run. That is a calm `null` with no probe error,
1464
+ // because there is nothing to report rather than something we failed to read.
1465
+ let scope = null;
1466
+ const declaredScopeId = typeof checkpoint.index_scope_id === "string" && checkpoint.index_scope_id.length > 0
1467
+ ? checkpoint.index_scope_id
1468
+ : null;
1469
+ if (access !== null && declaredScopeId !== null) {
1470
+ const scopeStatus = await getIndexScopeStatus(access, declaredScopeId, deps.fetchImpl);
1471
+ if (!scopeStatus.ok) {
1472
+ probeErrors.push({ probe: "scope", reason: scopeStatus.error });
1473
+ // An unread scope is reported as explicitly `unavailable` rather than left
1474
+ // null: a null could be read as "no scope declared", and that reading would
1475
+ // let the loop proceed past a freshness question it never got an answer to.
1476
+ scope = {
1477
+ scope_id: declaredScopeId,
1478
+ lifecycle_state: null,
1479
+ freshness_status: "unavailable",
1480
+ blocked_reason: null,
1481
+ required_commit_sha: null,
1482
+ indexed_commit_sha: null,
1483
+ last_error: null,
1484
+ };
1485
+ }
1486
+ else {
1487
+ scope = {
1488
+ scope_id: scopeStatus.value.scope_id,
1489
+ lifecycle_state: scopeStatus.value.lifecycle_state,
1490
+ // Fail closed: the client already narrowed an unrecognized value to null,
1491
+ // and null here means "not fresh", never "fine".
1492
+ freshness_status: scopeStatus.value.freshness_status ?? "unavailable",
1493
+ blocked_reason: scopeStatus.value.blocked_reason,
1494
+ required_commit_sha: scopeStatus.value.required_commit_sha,
1495
+ indexed_commit_sha: scopeStatus.value.indexed_commit_sha,
1496
+ last_error: scopeStatus.value.last_error,
1497
+ };
1498
+ }
1499
+ }
1500
+ // --- BAPI-846: the repository's index scopes, and this epic's heartbeat ---
1501
+ //
1502
+ // TWO distinct jobs, both belonging here rather than in a daemon:
1503
+ //
1504
+ // 1. The LISTING makes a crashed epic's scope discoverable. `scope` above
1505
+ // answers "is MY index fresh?"; this answers "what index scopes exist, and
1506
+ // which of them is stranded?" — including expired, reclaiming, and reclaimed
1507
+ // ones, which is what makes a stale scope actionable without SQL.
1508
+ // 2. The HEARTBEAT renews this epic's lease. The pilot has no long-lived local
1509
+ // process — `/loop 5m /conduct-epic` re-invokes this command on a timer, so
1510
+ // the tick loop IS the heartbeat owner, and its cadence matches the default
1511
+ // heartbeat interval. Beating from here means ownership is renewed exactly
1512
+ // while a conductor is actively driving the epic, and stops the moment it
1513
+ // stops ticking, with no untracked daemon and no shutdown contract to get
1514
+ // wrong.
1515
+ //
1516
+ // Both are probes: a failure lands in `probe_errors` and never fails `status`.
1517
+ let scopes = [];
1518
+ let retentionSeconds = null;
1519
+ let nextLeaseEpoch = checkpoint.index_scope_lease_epoch;
1520
+ if (access !== null) {
1521
+ const listing = await getIndexScopeLifecycle(access, deps.fetchImpl);
1522
+ if (!listing.ok) {
1523
+ probeErrors.push({ probe: "scopes", reason: listing.error });
1524
+ }
1525
+ else {
1526
+ scopes = listing.value.scopes;
1527
+ retentionSeconds = listing.value.retention_seconds;
1528
+ }
1529
+ if (declaredScopeId !== null) {
1530
+ // The epoch comes from the SERVER's listing when it is readable, and from
1531
+ // the checkpoint only as a fallback. Preferring the server is what lets a
1532
+ // conductor keep beating after an operator `recover` superseded its
1533
+ // generation — the alternative is a healthy conductor permanently fenced by
1534
+ // a stale local number.
1535
+ const entry = scopes.find((scope) => scope.scope_id === declaredScopeId);
1536
+ const epoch = entry?.lease_epoch ?? checkpoint.index_scope_lease_epoch;
1537
+ if (epoch !== null && entry?.recoverable !== false) {
1538
+ const beat = await heartbeatIndexScope(access, { scopeId: declaredScopeId, leaseEpoch: epoch }, deps.fetchImpl);
1539
+ if (beat.ok) {
1540
+ nextLeaseEpoch = beat.value.lease_epoch;
1541
+ }
1542
+ else {
1543
+ // A rejected beat means fenced or retired. Recorded as a probe error so
1544
+ // the loop SEES it, not swallowed — but never fatal, because `status`
1545
+ // is also how an operator finds out they were fenced.
1546
+ probeErrors.push({ probe: "scope_heartbeat", reason: beat.error });
1547
+ }
1548
+ }
1549
+ }
1550
+ }
1061
1551
  // --- lock (inspected, NEVER acquired) ------------------------------------
1062
1552
  const lockState = await inspectConductEpicLock(resolveConductEpicLockPath(checkpointPath), lockRequest(deps), buildConductEpicLockSeams(deps));
1063
1553
  const lock = {
@@ -1070,9 +1560,14 @@ export async function runConductEpicStatus(deps, options) {
1070
1560
  ? false
1071
1561
  : null,
1072
1562
  };
1073
- // --- the four permitted writes -------------------------------------------
1563
+ // --- the five permitted writes -------------------------------------------
1564
+ // BAPI-846 added the fifth: the scope's fencing epoch, refreshed from the
1565
+ // server's authoritative answer. It rides in the SAME atomic write as the other
1566
+ // four rather than in a second one, so a tick either records everything it
1567
+ // observed or nothing.
1074
1568
  let lastSeenHead = ticket?.last_seen_head ?? null;
1075
1569
  let lastStateChangeAt = ticket?.last_state_change_at ?? null;
1570
+ const leaseEpochChanged = nextLeaseEpoch !== checkpoint.index_scope_lease_epoch;
1076
1571
  if (ticket !== null) {
1077
1572
  const next = { ...checkpoint, tickets: [...checkpoint.tickets] };
1078
1573
  const index = next.tickets.findIndex((entry) => entry.key === ticket.key);
@@ -1094,6 +1589,10 @@ export async function runConductEpicStatus(deps, options) {
1094
1589
  next.ci_last_poll = ci.ci_last_poll;
1095
1590
  dirty = true;
1096
1591
  }
1592
+ if (leaseEpochChanged) {
1593
+ next.index_scope_lease_epoch = nextLeaseEpoch;
1594
+ dirty = true;
1595
+ }
1097
1596
  if (dirty) {
1098
1597
  next.updated_at = now.toISOString();
1099
1598
  const written = await writeConductEpicCheckpointAtomic(checkpointPath, next, deps.fs, {
@@ -1128,11 +1627,104 @@ export async function runConductEpicStatus(deps, options) {
1128
1627
  hard_seconds: checkpoint.deadlines.hard_seconds,
1129
1628
  elapsed_since_spawn_seconds: elapsedSeconds(ticket?.spawned_at ?? null, now),
1130
1629
  },
1630
+ scope,
1631
+ scope_lease_epoch: nextLeaseEpoch,
1632
+ retention_seconds: retentionSeconds,
1633
+ scopes,
1131
1634
  lock,
1132
1635
  needs_human: checkpoint.needs_human,
1133
1636
  probe_errors: probeErrors,
1134
1637
  };
1135
- return emitSuccess(deps, options.json, payload);
1638
+ return emitSuccess(deps, options.json, payload, [
1639
+ ...renderScopeFreshnessLines(scope),
1640
+ ...renderStrandedScopeLines(scopes, declaredScopeId),
1641
+ ]);
1642
+ }
1643
+ /**
1644
+ * Render the compact freshness unit for the human-readable `status` output.
1645
+ *
1646
+ * There is no dashboard for an index scope and this deliberately does not invent
1647
+ * one — the unit lives on the status surface that already exists. Its shape is
1648
+ * fixed by what an operator needs to decide, in that order:
1649
+ *
1650
+ * 1. **What is happening to the ticket**, first and in plain language. "Waiting
1651
+ * for index refresh" is the answer to the question actually being asked; a
1652
+ * lifecycle name is not.
1653
+ * 2. **The lifecycle**, then the two commits on SEPARATE, SEPARATELY LABELLED
1654
+ * lines. Printing them together, or printing only one, is what let "the commit
1655
+ * we must index" read as "the commit we indexed".
1656
+ * 3. **The refusal, spelled out**, when there is one. A controlled token is
1657
+ * precise but not self-explaining, so each is given a sentence — and each
1658
+ * sentence distinguishes it from a plain parse failure.
1659
+ *
1660
+ * Returns an empty array when the epic declares no scope, so an epic without one
1661
+ * prints exactly what it printed before.
1662
+ */
1663
+ /**
1664
+ * Render the STRANDED-SCOPE warning unit for `status` (BAPI-846).
1665
+ *
1666
+ * Deliberately narrow: it names only scopes that are not this epic's and are not
1667
+ * live — the crashed-epic case an operator can act on — and it says nothing at
1668
+ * all when there are none. A full inventory belongs in the JSON payload; the
1669
+ * human output exists to make one specific problem impossible to miss, and a
1670
+ * block that prints on every healthy tick is a block operators stop reading.
1671
+ *
1672
+ * `reclaimed` scopes are omitted: a tombstone is a completed outcome, not
1673
+ * something to act on.
1674
+ */
1675
+ function renderStrandedScopeLines(scopes, ownScopeId) {
1676
+ const stranded = scopes.filter((scope) => scope.scope_id !== ownScopeId &&
1677
+ scope.lifecycle_state !== "reclaimed" &&
1678
+ !scope.lease_valid);
1679
+ if (stranded.length === 0)
1680
+ return [];
1681
+ const lines = [
1682
+ `${stranded.length} index scope(s) in this repository have no live lease:`,
1683
+ ];
1684
+ for (const scope of stranded) {
1685
+ const action = scope.recoverable
1686
+ ? "recoverable — `conduct-epic recover <EPIC> --scope " + scope.scope_id + "`"
1687
+ : scope.retention_elapsed
1688
+ ? "past retention — the sweep will reclaim it"
1689
+ : `retained until ${scope.retention_deadline ?? "an unknown deadline"}`;
1690
+ lines.push(` ${scope.scope_id} ${scope.lifecycle_state ?? "unknown"} ` +
1691
+ `branch=${scope.feature_branch ?? "unknown"} ${action}`);
1692
+ if (scope.blockers.length > 0) {
1693
+ lines.push(` blocked by: ${scope.blockers.join(", ")}`);
1694
+ }
1695
+ }
1696
+ return lines;
1697
+ }
1698
+ function renderScopeFreshnessLines(scope) {
1699
+ if (scope === null)
1700
+ return [];
1701
+ const headline = {
1702
+ fresh: "Index is fresh for this epic.",
1703
+ pending: "Waiting for index refresh.",
1704
+ blocked: "Index refresh is BLOCKED — this advance will not be indexed.",
1705
+ failed: "Index generation FAILED for this scope.",
1706
+ unavailable: "Index freshness is unavailable — treat as not fresh.",
1707
+ };
1708
+ const refusal = {
1709
+ advance_blocked_base_merge: "the base branch was merged forward into the epic branch, which would move the branch's pinned cut point",
1710
+ advance_blocked_unexpected_parent: "the merge commit does not descend directly from the head this scope pinned, so it is not a worker merge",
1711
+ advance_blocked_history_changed: "the pinned head is gone from the branch's history — a force-push or rewrite",
1712
+ advance_blocked_unverifiable: "the advance could not be verified at all, and doubt blocks rather than indexes",
1713
+ };
1714
+ const lines = [
1715
+ headline[scope.freshness_status] ?? "Index freshness is unknown — treat as not fresh.",
1716
+ ` lifecycle: ${scope.lifecycle_state ?? "unknown"}`,
1717
+ ` Required commit: ${scope.required_commit_sha ?? "none"}`,
1718
+ ` Indexed commit: ${scope.indexed_commit_sha ?? "none"}`,
1719
+ ];
1720
+ if (scope.blocked_reason !== null) {
1721
+ lines.push(` Reason: ${scope.blocked_reason} — ${refusal[scope.blocked_reason] ?? "the server refused this branch advance"}`);
1722
+ lines.push(" A human must resolve the branch before the epic can continue.");
1723
+ }
1724
+ else if (scope.freshness_status === "failed" && scope.last_error !== null) {
1725
+ lines.push(` Failure category: ${scope.last_error}`);
1726
+ }
1727
+ return lines;
1136
1728
  }
1137
1729
  /**
1138
1730
  * Project one checkpoint ticket into the published `ticket` facts.
@@ -1321,7 +1913,6 @@ export function normalizeParseStatus(value) {
1321
1913
  terminal: status === "succeeded" || status === "failed",
1322
1914
  started_at: optionalText("started_at"),
1323
1915
  finished_at: optionalText("finished_at"),
1324
- index_branch_override: optionalText("index_branch_override"),
1325
1916
  };
1326
1917
  }
1327
1918
  // ---------------------------------------------------------------------------
@@ -1620,12 +2211,27 @@ export async function runConductEpicSpawn(deps, options) {
1620
2211
  catch {
1621
2212
  return emitFailure(deps, options.json, [`The prompt file '${options.promptFile}' could not be read.`]);
1622
2213
  }
2214
+ // BAPI-844: the scope this epic was cut against, read ONLY from the durable
2215
+ // server-returned value on the checkpoint. `process.env.BAPI_INDEX_SCOPE` is
2216
+ // deliberately not consulted — an operator's shell is not the epic's routing
2217
+ // decision, and a pilot ticket has no server-side membership to fall back on,
2218
+ // so an ambient value would silently route this worker's research somewhere
2219
+ // nobody chose. A malformed recorded value stops the spawn with the fixed
2220
+ // configuration error rather than launching an unscoped (canonical) worker.
2221
+ let indexScope;
2222
+ try {
2223
+ indexScope = validateOptionalIndexScope(checkpoint.index_scope_id);
2224
+ }
2225
+ catch {
2226
+ return emitFailure(deps, options.json, [INDEX_SCOPE_CONFIGURATION_ERROR]);
2227
+ }
1623
2228
  const spawned = await spawnConductEpicAgentTab({
1624
2229
  ticketKey,
1625
2230
  worktreePath: found.path,
1626
2231
  prompt,
1627
2232
  agent: options.agent,
1628
2233
  platform: deps.platform,
2234
+ ...(indexScope === undefined ? {} : { indexScope }),
1629
2235
  }, deps.spawnTab);
1630
2236
  if (!spawned.ok)
1631
2237
  return emitFailure(deps, options.json, [spawned.error]);
@@ -1667,14 +2273,14 @@ export async function runConductEpicSpawn(deps, options) {
1667
2273
  // finish
1668
2274
  // ---------------------------------------------------------------------------
1669
2275
  /**
1670
- * `conduct-epic finish` — restore the server's indexed base branch and wind down.
2276
+ * `conduct-epic finish` — retire the epic's index scope and wind down.
1671
2277
  *
1672
- * Restoration uses `restoreIndexBranch(access)` and NOTHING else. The
1673
- * checkpoint's `base_branch_original` is display-only: the server holds the
1674
- * durable override row with the true original, and a local copy that drifted
1675
- * (because the operator changed the repository's base mid-run) would restore the
1676
- * index to the wrong branch. `{ ok: true, changed: false }` is a success that
1677
- * is what a second `finish` sees.
2278
+ * BAPI-847: there is nothing to RESTORE. `init` no longer repoints the
2279
+ * repository's `base_branch`, so `finish` has no repository-wide configuration
2280
+ * to put back it retires the scope the epic was cut against and reports that
2281
+ * scope's lifecycle outcome and nothing else. The checkpoint's
2282
+ * `base_branch_original` remains display-only: it records which base the epic was
2283
+ * cut from, never a value this verb writes anywhere.
1678
2284
  */
1679
2285
  export async function runConductEpicFinish(deps, options) {
1680
2286
  const accessProbe = await resolveAccess(deps);
@@ -1693,19 +2299,44 @@ export async function runConductEpicFinish(deps, options) {
1693
2299
  if (!lock.acquired) {
1694
2300
  return emitFailure(deps, options.json, [`The epic lock could not be acquired: ${lock.reason}`]);
1695
2301
  }
1696
- const restored = await restoreIndexBranch(access, deps.fetchImpl);
1697
- if (!restored.ok) {
1698
- await releaseAcquired(lock);
1699
- return emitFailure(deps, options.json, [`The repository index could not be restored: ${restored.error}`]);
2302
+ // BAPI-846: `finish` RETIRES the epic's index scope; it never deletes it. The
2303
+ // scope keeps every Postgres row and every Pinecone namespace and stays
2304
+ // readable for post-mortem for the whole retention window — deletion is always
2305
+ // the scheduled sweep's or an explicit `reclaim`'s.
2306
+ //
2307
+ // Retirement runs INSIDE the lock, before it is released, so a concurrent
2308
+ // `finish` cannot interleave with it. It calls the retire API and never a
2309
+ // namespace or database deletion, and enough local state is retained (the
2310
+ // checkpoint keeps `index_scope_id` and the epoch) that a failed retirement can
2311
+ // simply be retried with `conduct-epic retire`.
2312
+ let scopeRetired = null;
2313
+ let scopeRetirementError = null;
2314
+ if (typeof checkpoint.index_scope_id === "string" && checkpoint.index_scope_id.length > 0) {
2315
+ const retirement = await retireScopeWithEpoch(deps, options, access, {
2316
+ scopeId: checkpoint.index_scope_id,
2317
+ checkpoint,
2318
+ leaseEpoch: checkpoint.index_scope_lease_epoch,
2319
+ });
2320
+ scopeRetired = retirement.ok;
2321
+ if (!retirement.ok)
2322
+ scopeRetirementError = retirement.reason;
1700
2323
  }
1701
2324
  await releaseAcquired(lock);
2325
+ // A failed retirement does NOT fail `finish`: the lock is already released and
2326
+ // every other wind-down step has happened, so reporting failure would invite a
2327
+ // re-run that redoes work already done. It is surfaced instead, with the one
2328
+ // command that fixes it — and the scope enters retention on lease expiry
2329
+ // regardless.
2330
+ if (scopeRetirementError !== null) {
2331
+ deps.errorLog(`The index scope was not retired: ${scopeRetirementError}. ` +
2332
+ `Retry with \`conduct-epic retire ${checkpoint.epic_key}\`.`);
2333
+ }
1702
2334
  const summary = {
1703
2335
  ok: true,
1704
2336
  epic_key: checkpoint.epic_key,
1705
2337
  epic_branch: checkpoint.epic_branch,
1706
- index_restored: true,
1707
- index_changed: restored.value.changed,
1708
- current_base_branch: restored.value.current_base_branch,
2338
+ /** `null` when the epic declares no scope; `false` when retirement failed. */
2339
+ scope_retired: scopeRetired,
1709
2340
  counters: { ...checkpoint.counters },
1710
2341
  needs_human: checkpoint.needs_human,
1711
2342
  tickets: checkpoint.tickets.map((ticket) => ({
@@ -1717,7 +2348,11 @@ export async function runConductEpicFinish(deps, options) {
1717
2348
  };
1718
2349
  const humanLines = [
1719
2350
  `Finished ${checkpoint.epic_key} (${checkpoint.epic_branch})`,
1720
- `index restore: ${restored.value.changed ? "restored" : "already restored"}`,
2351
+ `index scope: ${scopeRetired === null
2352
+ ? "none declared"
2353
+ : scopeRetired
2354
+ ? "retired (retention clock started; nothing deleted)"
2355
+ : "NOT retired — see the error above"}`,
1721
2356
  `iterations: ${checkpoint.counters.iterations} merges: ${checkpoint.counters.merges}`,
1722
2357
  ...checkpoint.tickets.map((ticket) => ` ${ticket.key} ${ticket.status} PR ${ticket.pr_number ?? "-"} ` +
1723
2358
  `spawned ${ticket.counters.sessions_spawned} plans ${ticket.counters.plan_generations_observed} ` +
@@ -1727,6 +2362,213 @@ export async function runConductEpicFinish(deps, options) {
1727
2362
  return emitSuccess(deps, options.json, summary, humanLines);
1728
2363
  }
1729
2364
  // ---------------------------------------------------------------------------
2365
+ // Index-scope lifecycle verbs (BAPI-846)
2366
+ // ---------------------------------------------------------------------------
2367
+ //
2368
+ // `recover`, `retire`, and `reclaim` all reach the authenticated Bridge API and
2369
+ // nothing else. There is deliberately no local Pinecone client, no SQL, and no
2370
+ // deletion path in this process: the server owns every destructive decision, and
2371
+ // a CLI that could delete directly would be a second authority with none of the
2372
+ // server's fencing, locking, or blocker checks.
2373
+ //
2374
+ // Each one persists the server's returned fencing epoch into the protected
2375
+ // checkpoint — never into argv or stdout — so a later heartbeat or retirement
2376
+ // uses the generation the server actually minted.
2377
+ /**
2378
+ * Resolve which scope a lifecycle verb targets.
2379
+ *
2380
+ * `--scope` wins when supplied; otherwise the epic's own scope is read from the
2381
+ * checkpoint. The explicit flag exists for the case the discovery surface is FOR:
2382
+ * a crashed epic whose local checkpoint is gone or was never written, whose scope
2383
+ * an operator found on `status`.
2384
+ */
2385
+ async function resolveLifecycleScope(deps, options, checkpointPath) {
2386
+ if (options.scope !== undefined) {
2387
+ // An explicitly named scope carries no local lease state, so any epoch must
2388
+ // come from the server. Reading one from an unrelated checkpoint would send a
2389
+ // generation that belongs to a different scope.
2390
+ return { ok: true, scopeId: options.scope, checkpoint: null, leaseEpoch: null };
2391
+ }
2392
+ const read = await readConductEpicCheckpoint(checkpointPath, deps.fs);
2393
+ if (read.kind === "missing") {
2394
+ return {
2395
+ ok: false,
2396
+ reason: `No checkpoint exists at ${checkpointPath}. ` +
2397
+ "Pass --scope <id> to target a scope directly (see `conduct-epic status`).",
2398
+ };
2399
+ }
2400
+ if (read.kind !== "ok")
2401
+ return { ok: false, reason: read.error };
2402
+ const scopeId = read.checkpoint.index_scope_id;
2403
+ if (typeof scopeId !== "string" || scopeId.length === 0) {
2404
+ return { ok: false, reason: `${options.epicKey} declares no index scope.` };
2405
+ }
2406
+ return {
2407
+ ok: true,
2408
+ scopeId,
2409
+ checkpoint: read.checkpoint,
2410
+ leaseEpoch: read.checkpoint.index_scope_lease_epoch,
2411
+ };
2412
+ }
2413
+ /**
2414
+ * Persist the server's authoritative fencing epoch into the checkpoint.
2415
+ *
2416
+ * Best-effort by design: the lifecycle call already succeeded on the server, and
2417
+ * failing the command because a local cache write failed would report a
2418
+ * successful recovery as a failure. The next `status` re-reads the epoch anyway.
2419
+ */
2420
+ async function persistScopeLeaseEpoch(deps, checkpointPath, checkpoint, leaseEpoch) {
2421
+ if (checkpoint === null || leaseEpoch === null)
2422
+ return;
2423
+ if (checkpoint.index_scope_lease_epoch === leaseEpoch)
2424
+ return;
2425
+ const next = {
2426
+ ...checkpoint,
2427
+ index_scope_lease_epoch: leaseEpoch,
2428
+ updated_at: deps.now().toISOString(),
2429
+ };
2430
+ await writeConductEpicCheckpointAtomic(checkpointPath, next, deps.fs, {
2431
+ skipChmod: deps.platform === "win32",
2432
+ });
2433
+ }
2434
+ /** `conduct-epic recover` — take a new ownership generation for a scope. */
2435
+ export async function runConductEpicRecover(deps, options) {
2436
+ const accessProbe = await resolveAccess(deps);
2437
+ if (!accessProbe.ok)
2438
+ return emitFailure(deps, options.json, [accessProbe.error]);
2439
+ const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
2440
+ const target = await resolveLifecycleScope(deps, options, checkpointPath);
2441
+ if (!target.ok)
2442
+ return emitFailure(deps, options.json, [target.reason]);
2443
+ const recovered = await recoverIndexScope(accessProbe.access, { scopeId: target.scopeId }, deps.fetchImpl);
2444
+ if (!recovered.ok) {
2445
+ return emitFailure(deps, options.json, [
2446
+ `The index scope could not be recovered: ${recovered.error}`,
2447
+ ]);
2448
+ }
2449
+ await persistScopeLeaseEpoch(deps, checkpointPath, target.checkpoint, recovered.value.lease_epoch);
2450
+ return emitSuccess(deps, options.json, {
2451
+ ok: true,
2452
+ epic_key: options.epicKey,
2453
+ scope_id: recovered.value.scope_id,
2454
+ lifecycle_state: recovered.value.lifecycle_state,
2455
+ lease_epoch: recovered.value.lease_epoch,
2456
+ lease_expires_at: recovered.value.lease_expires_at,
2457
+ }, [
2458
+ `Recovered index scope ${recovered.value.scope_id}.`,
2459
+ ` lifecycle: ${recovered.value.lifecycle_state ?? "unknown"}`,
2460
+ ` lease epoch: ${recovered.value.lease_epoch ?? "unknown"} (previous owners are now fenced)`,
2461
+ ` lease expires: ${recovered.value.lease_expires_at ?? "unknown"}`,
2462
+ ]);
2463
+ }
2464
+ /** `conduct-epic retire` — start the retention clock; delete nothing. */
2465
+ export async function runConductEpicRetire(deps, options) {
2466
+ const accessProbe = await resolveAccess(deps);
2467
+ if (!accessProbe.ok)
2468
+ return emitFailure(deps, options.json, [accessProbe.error]);
2469
+ const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
2470
+ const target = await resolveLifecycleScope(deps, options, checkpointPath);
2471
+ if (!target.ok)
2472
+ return emitFailure(deps, options.json, [target.reason]);
2473
+ const outcome = await retireScopeWithEpoch(deps, options, accessProbe.access, target);
2474
+ if (!outcome.ok)
2475
+ return emitFailure(deps, options.json, [outcome.reason]);
2476
+ return emitSuccess(deps, options.json, {
2477
+ ok: true,
2478
+ epic_key: options.epicKey,
2479
+ scope_id: outcome.state.scope_id,
2480
+ lifecycle_state: outcome.state.lifecycle_state,
2481
+ lease_epoch: outcome.state.lease_epoch,
2482
+ already_retired: outcome.state.already_retired,
2483
+ }, [
2484
+ outcome.state.already_retired
2485
+ ? `Index scope ${outcome.state.scope_id} was already retired; retention clock unchanged.`
2486
+ : `Retired index scope ${outcome.state.scope_id}. Nothing was deleted.`,
2487
+ " The scope stays readable for post-mortem for the whole retention window.",
2488
+ ]);
2489
+ }
2490
+ /**
2491
+ * Retire a scope, resolving the fencing epoch the server currently holds.
2492
+ *
2493
+ * The epoch is the one thing retirement needs and the one thing a local
2494
+ * checkpoint can be wrong about — an operator `recover` (or another conductor)
2495
+ * may have superseded it. So a locally-cached epoch is used only as a first
2496
+ * attempt; on a fencing refusal the CURRENT epoch is read from the server's
2497
+ * listing and the retirement is retried ONCE. That is not a retry loop papering
2498
+ * over a race: retirement is idempotent and the second attempt uses an epoch the
2499
+ * server itself just reported.
2500
+ */
2501
+ async function retireScopeWithEpoch(deps, options, access, target) {
2502
+ let epoch = target.leaseEpoch;
2503
+ if (epoch === null) {
2504
+ const current = await lookupScopeEpoch(deps, access, target.scopeId);
2505
+ if (current === null) {
2506
+ return {
2507
+ ok: false,
2508
+ reason: `The current fencing epoch for scope ${target.scopeId} could not be read.`,
2509
+ };
2510
+ }
2511
+ epoch = current;
2512
+ }
2513
+ let retired = await retireIndexScope(access, { scopeId: target.scopeId, leaseEpoch: epoch }, deps.fetchImpl);
2514
+ if (!retired.ok) {
2515
+ const current = await lookupScopeEpoch(deps, access, target.scopeId);
2516
+ if (current !== null && current !== epoch) {
2517
+ epoch = current;
2518
+ retired = await retireIndexScope(access, { scopeId: target.scopeId, leaseEpoch: epoch }, deps.fetchImpl);
2519
+ }
2520
+ }
2521
+ if (!retired.ok) {
2522
+ return { ok: false, reason: `The index scope could not be retired: ${retired.error}` };
2523
+ }
2524
+ const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
2525
+ await persistScopeLeaseEpoch(deps, checkpointPath, target.checkpoint, retired.value.lease_epoch);
2526
+ return { ok: true, state: retired.value };
2527
+ }
2528
+ /** Read one scope's CURRENT fencing epoch from the authoritative listing. */
2529
+ async function lookupScopeEpoch(deps, access, scopeId) {
2530
+ const listing = await getIndexScopeLifecycle(access, deps.fetchImpl);
2531
+ if (!listing.ok)
2532
+ return null;
2533
+ const entry = listing.value.scopes.find((scope) => scope.scope_id === scopeId);
2534
+ return entry?.lease_epoch ?? null;
2535
+ }
2536
+ /** `conduct-epic reclaim` — ask the server to schedule the scope's teardown. */
2537
+ export async function runConductEpicReclaim(deps, options) {
2538
+ const accessProbe = await resolveAccess(deps);
2539
+ if (!accessProbe.ok)
2540
+ return emitFailure(deps, options.json, [accessProbe.error]);
2541
+ const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
2542
+ const target = await resolveLifecycleScope(deps, options, checkpointPath);
2543
+ if (!target.ok)
2544
+ return emitFailure(deps, options.json, [target.reason]);
2545
+ const scheduled = await reclaimIndexScope(accessProbe.access, { scopeId: target.scopeId, overrideRetention: options.overrideRetention }, deps.fetchImpl);
2546
+ if (!scheduled.ok) {
2547
+ // A refusal names the BLOCKERS the server evaluated, so an operator learns
2548
+ // that a parse is running rather than that "reclaim failed".
2549
+ const blockers = scheduled.blockers ?? [];
2550
+ const reasons = [`The index scope could not be reclaimed: ${scheduled.error}`];
2551
+ if (blockers.length > 0)
2552
+ reasons.push(` blocked by: ${blockers.join(", ")}`);
2553
+ return emitFailure(deps, options.json, reasons, {
2554
+ epic_key: options.epicKey,
2555
+ scope_id: target.scopeId,
2556
+ blockers,
2557
+ });
2558
+ }
2559
+ return emitSuccess(deps, options.json, {
2560
+ ok: true,
2561
+ epic_key: options.epicKey,
2562
+ scope_id: scheduled.value.scope_id ?? target.scopeId,
2563
+ scheduled: scheduled.value.scheduled,
2564
+ }, [
2565
+ `Scheduled reclamation of index scope ${scheduled.value.scope_id ?? target.scopeId}.`,
2566
+ " This is SCHEDULED, not done — the teardown waits out Pinecone's",
2567
+ " consistency window. Run `conduct-epic status --json` to see it reach",
2568
+ " `reclaimed`.",
2569
+ ]);
2570
+ }
2571
+ // ---------------------------------------------------------------------------
1730
2572
  // Entry point
1731
2573
  // ---------------------------------------------------------------------------
1732
2574
  /**
@@ -1761,5 +2603,11 @@ export async function runConductEpicCli(argv, overrides = {}) {
1761
2603
  return runConductEpicSpawn(deps, options);
1762
2604
  case "finish":
1763
2605
  return runConductEpicFinish(deps, options);
2606
+ case "recover":
2607
+ return runConductEpicRecover(deps, options);
2608
+ case "retire":
2609
+ return runConductEpicRetire(deps, options);
2610
+ case "reclaim":
2611
+ return runConductEpicReclaim(deps, options);
1764
2612
  }
1765
2613
  }