@bridge_gpt/mcp-server 0.2.53 → 0.2.55

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 (103) hide show
  1. package/README.md +116 -56
  2. package/build/agent-guidance.generated.js +3 -0
  3. package/build/agent-launchers/claude-executor-adapter.js +3 -0
  4. package/build/agent-launchers/claude.js +3 -3
  5. package/build/agent-launchers/prompt.js +8 -11
  6. package/build/agent-notes.js +178 -0
  7. package/build/agent-registry.js +5 -2
  8. package/build/agent-utils.js +58 -0
  9. package/build/agents.generated.js +1 -1
  10. package/build/base-ref.js +33 -9
  11. package/build/bounded-wait.js +174 -0
  12. package/build/codex-skill-adapter.js +55 -0
  13. package/build/commands.generated.js +6 -5
  14. package/build/conductor/bridge-api-client.js +235 -14
  15. package/build/conductor/bring-up-facts.js +187 -0
  16. package/build/conductor/claude-hook.js +7 -5
  17. package/build/conductor/cli.js +28 -0
  18. package/build/conductor/doctor.js +80 -9
  19. package/build/conductor/epic-implementer-cli.js +1298 -0
  20. package/build/conductor/epic-runtime.js +134 -98
  21. package/build/conductor/errors.js +2 -2
  22. package/build/conductor/git-ci-types.js +1 -1
  23. package/build/conductor/git-hooks.js +28 -14
  24. package/build/conductor/install-doctor.js +11 -5
  25. package/build/conductor/readiness-cli.js +10 -10
  26. package/build/conductor/readiness-sections.js +58 -9
  27. package/build/conductor/readiness.js +120 -4
  28. package/build/conductor/recovery-cli.js +289 -10
  29. package/build/conductor/recovery-operations.js +125 -2
  30. package/build/conductor/repair-contract.js +58 -0
  31. package/build/conductor/run-branch.js +137 -0
  32. package/build/conductor/store.js +2 -2
  33. package/build/conductor/supervisor-runtime.js +1 -1
  34. package/build/conductor/test-run-branch-vectors.js +165 -0
  35. package/build/conductor-bin.js +2 -139
  36. package/build/conductor-claude-hook-bin.js +2 -2
  37. package/build/conductor-claude-hook-removed-stub-bin.js +31 -0
  38. package/build/conductor-removed-stub-bin.js +30 -0
  39. package/build/docs.generated.js +1 -1
  40. package/build/doctor.js +145 -18
  41. package/build/drive-epic.js +752 -90
  42. package/build/epic-implementer-bin.js +145 -0
  43. package/build/epic-implementer-bundle-cli.js +264 -0
  44. package/build/epic-implementer-claude-hook-bin.js +3 -0
  45. package/build/epic-integration-pr.js +5 -3
  46. package/build/executor/claim-scope.js +104 -0
  47. package/build/executor/cli.js +14 -25
  48. package/build/executor/env-file-guard.js +82 -3
  49. package/build/executor/env.js +6 -0
  50. package/build/executor/interrupted-worktree.js +60 -0
  51. package/build/executor/job-errors.js +45 -0
  52. package/build/executor/job-runner.js +334 -9
  53. package/build/executor/job-types.js +25 -9
  54. package/build/executor/merge-tree-classifier.js +171 -0
  55. package/build/executor/reconcile-overlap-governance.js +129 -0
  56. package/build/executor/reconcile-overlap-job.js +989 -0
  57. package/build/executor/reconcile-overlap-types.js +14 -0
  58. package/build/executor/spawn-job-driver.js +1 -0
  59. package/build/executor/types.js +2 -0
  60. package/build/executor/worker-finalization.js +25 -2
  61. package/build/executor/worker-guard-hook.js +15 -7
  62. package/build/implement-epic/bridge-client.js +773 -0
  63. package/build/implement-epic/checkpoint-store.js +542 -0
  64. package/build/implement-epic/cli.js +3158 -0
  65. package/build/implement-epic/cut-protocol.js +392 -0
  66. package/build/implement-epic/lock.js +302 -0
  67. package/build/implement-epic/pr-state.js +286 -0
  68. package/build/implement-epic/spawn.js +113 -0
  69. package/build/index.js +657 -481
  70. package/build/init.js +72 -8
  71. package/build/install-bridge-conductor.js +5 -5
  72. package/build/install-bridge.js +403 -70
  73. package/build/local-artifact-storage.js +130 -0
  74. package/build/mcp-host-config.js +22 -60
  75. package/build/mcp-host-entry-adapter.js +18 -0
  76. package/build/mcp-host-targets.js +1 -21
  77. package/build/merge-pull-request.js +1 -1
  78. package/build/pipelines.generated.js +23 -16
  79. package/build/plan-epic-conductor-eligibility.js +1 -1
  80. package/build/plane/cli.js +321 -41
  81. package/build/plane/manifest.js +209 -1
  82. package/build/plane/member-roster.js +70 -0
  83. package/build/plane/preflight.js +128 -12
  84. package/build/plane/shutdown.js +18 -5
  85. package/build/plane/status.js +35 -1
  86. package/build/plane/supervisor.js +546 -164
  87. package/build/plane/types.js +25 -2
  88. package/build/polling-policy.js +72 -0
  89. package/build/readiness-check.js +3 -3
  90. package/build/readme.generated.js +1 -1
  91. package/build/review-generation.js +219 -0
  92. package/build/run-unit-tests-launcher.js +6 -1
  93. package/build/setup-epic.js +567 -38
  94. package/build/start-tickets-conductor.js +8 -7
  95. package/build/ticket-key-utils.js +4 -3
  96. package/build/ticket-review-artifact-gate.js +461 -0
  97. package/build/upgrade-cli.js +5 -26
  98. package/build/version.generated.js +3 -3
  99. package/build/worker-guard-hook-bin.js +1 -1
  100. package/docs/CONDUCTOR.md +8 -6
  101. package/docs/install/mcp-tool-integrations.md +23 -1
  102. package/package.json +5 -3
  103. package/pipelines/review-ticket.json +17 -4
@@ -0,0 +1,3158 @@
1
+ /**
2
+ * The packaged `implement-epic` CLI (BAPI-803).
3
+ *
4
+ * `/conduct-epic` (BAPI-804) is prose. (S6 seam: this slash-command mirror is
5
+ * renamed separately from the packaged `implement-epic` CLI below — S4/BAPI-1080
6
+ * renames only the packaged positional command.) Prose cannot be unit-tested, and the
7
+ * guarantees the loop depends on — that the epic branch was created from the
8
+ * right base, that a checkpoint write is atomic, that two sessions cannot edit
9
+ * one checkpoint, that a `MERGED` PR is visible, that `auto_merge_enabled` is on
10
+ * before the first ticket is dispatched — are exactly the kind that fail
11
+ * silently when they live in an instruction file. So they live here, behind the
12
+ * verbs the loop calls and this file's tests pin:
13
+ *
14
+ * init | status | checkpoint set | finish | spawn
15
+ * recover | retire | reclaim | catch-up (index-scope lifecycle)
16
+ * scopes (repository-scoped inventory)
17
+ *
18
+ * `scopes` (BAPI-963) is the one verb that takes no `<EPIC>` key: it answers
19
+ * "what does this repository own?", which is not a question about any one epic.
20
+ *
21
+ * Design rules that hold across all of them:
22
+ *
23
+ * - **Nothing creates or mutates an `epic_run`.** This CLI drives a LOCAL loop.
24
+ * The one epic-run call it makes (`getEpicRunState`) is a read whose only
25
+ * purpose is to REFUSE when the server-side v2 reconciler is already
26
+ * conducting the same epic — two conductors on one epic is the failure mode.
27
+ * - **Every process/network/filesystem boundary is injected** through
28
+ * {@link ConductEpicDeps}, so the whole surface is testable without a real
29
+ * `gh`, terminal, home directory, or Bridge server.
30
+ * - **`init` accumulates.** Its preflight runs every independent check and
31
+ * prints every failure in one pass. Failing on the first one makes an operator
32
+ * discover a five-problem environment five invocations at a time.
33
+ * - **`status` fails open per probe, `status` never takes the lock.** A `gh`
34
+ * outage must not stop the loop from seeing its checkpoint; a read-only
35
+ * surface that took a lock would deadlock against the verb that needs it.
36
+ * - **Credentials resolve only through `resolveConductorBridgeApiAccess`** and
37
+ * never enter argv, stdout, stderr, a journal line, or an error string.
38
+ */
39
+ import { promises as nodeFs } from "node:fs";
40
+ import os from "node:os";
41
+ import path from "node:path";
42
+ import { validateBranchName } from "../base-ref.js";
43
+ import { parseDoneGateConfig } from "../conductor/done-gate.js";
44
+ import { createProductionEpicIntegrationGhRunner, ensureEpicIntegrationPullRequest, formatEpicIntegrationPullRequestOutcome, } from "../epic-integration-pr.js";
45
+ import { resolveConductorBridgeApiAccess, } from "../conductor/bridge-api-client.js";
46
+ import { runGhCommand } from "../conductor/pr-discovery.js";
47
+ import { getDefaultSpawnTerminalTabForPlatform, detectTerminal, createDefaultStartTicketsDeps, } from "../start-tickets.js";
48
+ import { resolveWorktrunkBinary } from "../start-tickets-prereqs.js";
49
+ import { resolveRequiredStartTicketsRepoName } from "../start-tickets-repo.js";
50
+ import { bootstrapIndexScope, getConfigFieldBaseBranch, getConductorReadiness, getIndexScopeLifecycle, catchUpIndexScope, getIndexScopeStatus, getEffectiveSupervisorConfig, getEffectiveSupervisorSetup, getEpicRunState, getParseDispatcherHealth, getParseStatus, getPrReviewStatus, heartbeatIndexScope, pollCiChecks, putSupervisorConfigDefaults, reclaimIndexScope, recoverIndexScope, resolveCiChecks, retireIndexScope, } from "./bridge-client.js";
51
+ import { appendTicketJournal, createInitialConductEpicCheckpoint, readConductEpicCheckpoint, resolveConductEpicCheckpointPath, resolveConductEpicLockPath, writeConductEpicCheckpointAtomic, CONDUCT_EPIC_REVIEW_VERDICTLESS_CEILING, CONDUCT_EPIC_TICKET_STATUSES, } from "./checkpoint-store.js";
52
+ import { acquireConductEpicLock, inspectConductEpicLock, isConductEpicLockOwnerAlive, } from "./lock.js";
53
+ import { discoverConductEpicPrState, discoverTicketWorktree, parseGitWorktreePorcelain, } from "./pr-state.js";
54
+ import { spawnConductEpicAgentTab, CONDUCT_EPIC_AGENTS, } from "./spawn.js";
55
+ import { MCP_PACKAGE_NAME } from "../mcp-identity.js";
56
+ import { fetchLatestVersion } from "../cli-release.js";
57
+ import { INDEX_SCOPE_CONFIGURATION_ERROR, validateOptionalIndexScope, } from "../index-scope-contract.js";
58
+ // BAPI-850: the exact-cut protocol, the scope-readiness poll bounds, and the
59
+ // local-git helpers live in ONE shared module that `setup-epic` drives too. This
60
+ // file remains the pilot's owner of the preflight and of how a cut outcome is
61
+ // reported; the cut itself is performed by the shared module.
62
+ import { createExecFileRunCommand, describeScopeBootstrapWindow, firstOutputLine as firstLine, formatScopeBootstrapHeartbeat, lsRemoteSha, normalizeCommitSha, performExactIndexScopeCut, runGit, SCOPE_BOOTSTRAP_MAX_POLLS, SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, SCOPE_BOOTSTRAP_UNREADABLE_STATE, } from "./cut-protocol.js";
63
+ // Re-exported so existing importers of the pilot's normalizer keep compiling.
64
+ export { normalizeCommitSha };
65
+ /** Epic and ticket keys accepted by every verb. */
66
+ export const CONDUCT_EPIC_KEY_PATTERN = /^[A-Z]+-[0-9]+$/;
67
+ /** The verb families. `checkpoint set` is two tokens, one verb. */
68
+ export const CONDUCT_EPIC_VERBS = [
69
+ "init",
70
+ "status",
71
+ "checkpoint set",
72
+ "finish",
73
+ "spawn",
74
+ "recover",
75
+ "retire",
76
+ "reclaim",
77
+ "catch-up",
78
+ "scopes",
79
+ ];
80
+ /** Per-ticket fields `checkpoint set` may assign. */
81
+ const TICKET_FIELDS = [
82
+ "status",
83
+ "branch",
84
+ "pr_number",
85
+ "spawned_at",
86
+ "parse_requested_at",
87
+ "parse_requested_for_sha",
88
+ "review_verdictless_observations",
89
+ "review_verdictless_for_sha",
90
+ "respawns",
91
+ "conflict_attempts",
92
+ "counters.sessions_spawned",
93
+ "counters.plan_generations_observed",
94
+ "counters.merge_attempts",
95
+ ];
96
+ /** Top-level fields `checkpoint set` may assign. */
97
+ const TOP_LEVEL_FIELDS = ["needs_human", "counters.iterations", "counters.merges"];
98
+ /** Build the production dependency set. */
99
+ export function createDefaultConductEpicDeps() {
100
+ const runCommand = createExecFileRunCommand();
101
+ const spawner = getDefaultSpawnTerminalTabForPlatform(process.platform);
102
+ const startTicketsDeps = createDefaultStartTicketsDeps();
103
+ // Strict enabled-value check (matches `MCP_INTEGRATION === "1"` in
104
+ // `integration/harness.ts`): only the exact value "1" activates the
105
+ // fixture, so no broadly truthy ambient environment value can enable it
106
+ // by accident.
107
+ const publishedIdentityFixture = process.env[CONDUCT_EPIC_PUBLISHED_IDENTITY_FIXTURE_ENV] === "1"
108
+ ? CONDUCT_EPIC_PUBLISHED_IDENTITY_FIXTURE
109
+ : undefined;
110
+ return {
111
+ runCommand,
112
+ runGh: (args, options) => runGhCommand(args, options ?? {}),
113
+ epicIntegrationGh: createProductionEpicIntegrationGhRunner(),
114
+ spawnTab: (shellCommand, context) => spawner(startTicketsDeps, detectTerminal(undefined, process.env), shellCommand, context),
115
+ fetchImpl: globalThis.fetch,
116
+ fs: {
117
+ mkdir: (dirPath, options) => nodeFs.mkdir(dirPath, options),
118
+ readFile: (filePath) => nodeFs.readFile(filePath, "utf-8"),
119
+ writeFile: (filePath, data, options) => nodeFs.writeFile(filePath, data, { encoding: "utf-8", mode: options?.mode }),
120
+ // Real exclusive create: `wx` fails EEXIST in the kernel, so two racing
121
+ // callers cannot both believe they created the file. Never reconstruct
122
+ // this from `stat` + `writeFile`.
123
+ writeFileExclusive: async (filePath, data, options) => {
124
+ const handle = await nodeFs.open(filePath, "wx", options?.mode);
125
+ try {
126
+ await handle.writeFile(data, "utf-8");
127
+ }
128
+ finally {
129
+ await handle.close();
130
+ }
131
+ },
132
+ rename: (fromPath, toPath) => nodeFs.rename(fromPath, toPath),
133
+ chmod: (filePath, mode) => nodeFs.chmod(filePath, mode),
134
+ unlink: (filePath) => nodeFs.unlink(filePath),
135
+ stat: (filePath) => nodeFs.stat(filePath),
136
+ },
137
+ now: () => new Date(),
138
+ env: process.env,
139
+ homedir: os.homedir,
140
+ hostname: os.hostname,
141
+ platform: process.platform,
142
+ cwd: process.cwd(),
143
+ pid: process.pid,
144
+ isProcessAlive: isConductEpicLockOwnerAlive,
145
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
146
+ log: (m) => console.log(m),
147
+ errorLog: (m) => console.error(m),
148
+ resolveAccess: resolveConductorBridgeApiAccess,
149
+ resolveLatestPublishedVersion: () => fetchLatestVersion({ fetch: globalThis.fetch }),
150
+ publishedIdentityFixture,
151
+ resolveRepoName: resolveRequiredStartTicketsRepoName,
152
+ };
153
+ }
154
+ // ---------------------------------------------------------------------------
155
+ // Usage + argument parsing
156
+ // ---------------------------------------------------------------------------
157
+ /** User-facing usage text for the packaged `implement-epic` subcommand. */
158
+ export function getConductEpicUsage() {
159
+ return [
160
+ "Usage:",
161
+ ` npx -y ${MCP_PACKAGE_NAME} implement-epic <verb> [flags]`,
162
+ "",
163
+ "implement-epic drives EPIC-LEVEL orchestration (a local, index-scope-lifecycle",
164
+ "loop over an epic's tickets). It is a DIFFERENT flow from the ticket-level",
165
+ "`implement-ticket` command, which implements one ticket; implement-epic never",
166
+ "replaces it.",
167
+ "",
168
+ "Verbs:",
169
+ " init <EPIC> --tickets K1,K2,... [--base-branch <b>] [--checkpoint-path <p>] [--dry-run] [--json]",
170
+ " Run the full preflight, then create epic/<EPIC> on origin at the commit the",
171
+ " CANONICAL INDEX covers — not the base tip — seed and verify the epic's index",
172
+ " scope at that commit, repoint the indexed branch, write the checkpoint, and",
173
+ " take the lock. --base-branch selects the base whose history is fetched and",
174
+ " recorded; the cut commit is the canonical indexed SHA and is reported",
175
+ " separately. init fails closed when the repository has no successful parse.",
176
+ " Every preflight failure is printed in one pass; nothing is written unless",
177
+ " all of them pass. --dry-run prints the validated plan and writes nothing.",
178
+ "",
179
+ " status <EPIC> [--checkpoint-path <p>] --json",
180
+ " Print one JSON object describing the in-flight ticket, its worktree, PR,",
181
+ " CI, review, parse, deadline, and lock state. --json is required. A missing",
182
+ " checkpoint exits 0 with checkpoint_exists:false. A failed probe leaves its",
183
+ " sub-object null and is listed in probe_errors; it never fails the command.",
184
+ " status warns about STRANDED scopes only; run `implement-epic scopes` below",
185
+ " for the full repository-wide inventory.",
186
+ "",
187
+ " scopes [--json]",
188
+ " Repository-scoped, and the ONE verb that takes no <EPIC> key. Lists EVERY",
189
+ " index scope this repository owns — live, expired, reclaiming, and",
190
+ " reclaimed alike — so a crashed epic is visible without SQL. Read-only:",
191
+ " it writes nothing and takes no lease.",
192
+ "",
193
+ " checkpoint set <EPIC> --ticket <KEY> [--field <name> <value>]... [--journal <line>]",
194
+ " [--checkpoint-path <p>]",
195
+ " Apply ABSOLUTE field values (the caller computes n+1 from status).",
196
+ ` Ticket fields: ${TICKET_FIELDS.join(", ")}.`,
197
+ ` Top-level fields: ${TOP_LEVEL_FIELDS.join(", ")}.`,
198
+ " Repeat --field to write several in ONE atomic mutation.",
199
+ " parse_requested_at / parse_requested_for_sha are ACCEPTED for older",
200
+ " checkpoints but no longer written: freshness is read from the scope.",
201
+ "",
202
+ " finish <EPIC> [--checkpoint-path <p>] [--json]",
203
+ " Restore the server's indexed base branch (idempotent), release the owned",
204
+ " lock, and print the final summary. A second finish succeeds.",
205
+ "",
206
+ ` spawn <EPIC> --ticket <KEY> --prompt-file <path> [--agent ${CONDUCT_EPIC_AGENTS.join("|")}]`,
207
+ " [--checkpoint-path <p>] [--json]",
208
+ " Open exactly ONE agent tab in the ticket's worktree running the prompt",
209
+ " file's contents, then increment counters.sessions_spawned and append a",
210
+ " journal line. Respawn and conflict budgets are the CALLER's job.",
211
+ "",
212
+ " recover <EPIC> [--scope <id>] [--checkpoint-path <p>] [--json]",
213
+ " Take a NEW ownership generation for a crashed epic's index scope and",
214
+ " record the returned fencing epoch locally. Use this instead of SQL when",
215
+ " `status` shows a scope whose lease expired. Defaults to the epic's own",
216
+ " scope; --scope targets another one (e.g. when the checkpoint is gone).",
217
+ "",
218
+ " retire <EPIC> [--scope <id>] [--checkpoint-path <p>] [--json]",
219
+ " Start the scope's retention clock. Deletes NOTHING — the scope stays",
220
+ " readable for post-mortem for the whole retention window. Idempotent.",
221
+ " `finish` does this for you; this verb is for retiring without finishing.",
222
+ "",
223
+ " catch-up <EPIC> [--scope <id>] [--checkpoint-path <p>] [--json]",
224
+ " GUARDED CATCH-UP: replay merges the server never observed, so a scope",
225
+ " stranded behind its epic branch can be repaired without waiting for the",
226
+ " next ticket to merge. Every replayed commit goes through the SAME AC-10",
227
+ " gate a merge webhook would have used — this replays verified merge",
228
+ " evidence, it does NOT force the branch into the index, and there is no",
229
+ " --force, --override, or skip-guard flag on purpose. A refusal leaves both",
230
+ " commit watermarks untouched and schedules no parse. Running it twice",
231
+ " leaves the same state as running it once.",
232
+ "",
233
+ " reclaim <EPIC> [--scope <id>] [--override-retention] [--checkpoint-path <p>] [--json]",
234
+ " Ask the server to schedule the scope's teardown: three Pinecone",
235
+ " namespaces, six parse-table slices, three config rows, and a retained",
236
+ " tombstone. Returns as soon as it is SCHEDULED; watch `status` for the",
237
+ " result. --override-retention waives only the still-valid-lease and",
238
+ " unelapsed-retention waits — an active parse, a held parse lock, a live",
239
+ " automation run, or a live epic run still refuse. There is no raw",
240
+ " deletion mode.",
241
+ "",
242
+ "Common:",
243
+ " -h, --help Show this help",
244
+ "",
245
+ "State lives outside the repository, at ~/.config/bridge/conduct/<repo>/ ",
246
+ "(honoring XDG_CONFIG_HOME): <EPIC>.json, <EPIC>.json.prev, <EPIC>.lock, and",
247
+ "<EPIC>/prompts/. The directory is 0700 and files are 0600.",
248
+ "",
249
+ "Exit codes: 0 on success (including checkpoint_exists:false and an idempotent",
250
+ "finish); non-zero on any failure, with a one-line reason on stderr. With --json,",
251
+ "stdout is exactly one JSON object carrying ok.",
252
+ ].join("\n");
253
+ }
254
+ function parseError(message) {
255
+ return { status: "error", message };
256
+ }
257
+ /** Which flags each verb accepts. An unknown flag for the verb is rejected. */
258
+ const VERB_FLAGS = {
259
+ init: ["--tickets", "--base-branch", "--checkpoint-path", "--dry-run", "--json"],
260
+ status: ["--checkpoint-path", "--json"],
261
+ "checkpoint-set": ["--ticket", "--field", "--journal", "--checkpoint-path"],
262
+ finish: ["--checkpoint-path", "--json"],
263
+ spawn: ["--ticket", "--prompt-file", "--agent", "--checkpoint-path", "--json"],
264
+ recover: ["--scope", "--checkpoint-path", "--json"],
265
+ retire: ["--scope", "--checkpoint-path", "--json"],
266
+ reclaim: ["--scope", "--override-retention", "--checkpoint-path", "--json"],
267
+ // BAPI-932. NOTE the flags that are deliberately absent: there is no --force,
268
+ // no --override, and no --skip-guards, and adding one would defeat the point of
269
+ // the verb. Catch-up replays commits the SERVER reads through the unchanged
270
+ // AC-10 gate; it cannot make that gate say yes.
271
+ "catch-up": ["--scope", "--checkpoint-path", "--json"],
272
+ // BAPI-963. `--json` ONLY: this verb reads a repository-wide listing, so there
273
+ // is no checkpoint to point at and no scope to single out. The absence of
274
+ // --force/--override/--skip-guard here is the same deliberate absence as above.
275
+ scopes: ["--json"],
276
+ };
277
+ /**
278
+ * Parse and fully validate argv BEFORE any I/O.
279
+ *
280
+ * Rejects unknown verbs, unknown flags, duplicate singleton options, missing
281
+ * values, malformed keys, invalid branch names, and stray positionals. Every one
282
+ * of those is a mistake a caller can make in a generated shell command, and
283
+ * catching them here means a bad invocation never spawns a process, resolves a
284
+ * credential, or touches the checkpoint.
285
+ */
286
+ export function parseConductEpicArgs(argv) {
287
+ if (argv.includes("-h") || argv.includes("--help")) {
288
+ return { status: "help", usage: getConductEpicUsage() };
289
+ }
290
+ if (argv.length === 0)
291
+ return parseError("A verb is required.");
292
+ let verb;
293
+ let rest;
294
+ if (argv[0] === "checkpoint") {
295
+ if (argv[1] !== "set") {
296
+ return parseError("Unknown verb 'checkpoint " + (argv[1] ?? "") + "'. Expected 'checkpoint set'.");
297
+ }
298
+ verb = "checkpoint-set";
299
+ rest = argv.slice(2);
300
+ }
301
+ else if (argv[0] === "init" ||
302
+ argv[0] === "status" ||
303
+ argv[0] === "finish" ||
304
+ argv[0] === "spawn" ||
305
+ argv[0] === "recover" ||
306
+ argv[0] === "retire" ||
307
+ argv[0] === "reclaim" ||
308
+ argv[0] === "catch-up" ||
309
+ argv[0] === "scopes") {
310
+ verb = argv[0];
311
+ rest = argv.slice(1);
312
+ }
313
+ else {
314
+ return parseError(`Unknown verb '${argv[0]}'. Expected one of: ${CONDUCT_EPIC_VERBS.join(", ")}.`);
315
+ }
316
+ const allowed = VERB_FLAGS[verb];
317
+ const options = {
318
+ verb,
319
+ epicKey: "",
320
+ tickets: [],
321
+ fields: [],
322
+ dryRun: false,
323
+ json: false,
324
+ overrideRetention: false,
325
+ };
326
+ const seen = new Set();
327
+ let epicKey;
328
+ for (let i = 0; i < rest.length; i += 1) {
329
+ const arg = rest[i];
330
+ if (!arg.startsWith("-")) {
331
+ if (epicKey !== undefined) {
332
+ return parseError(`Unexpected argument '${arg}'.`);
333
+ }
334
+ epicKey = arg;
335
+ continue;
336
+ }
337
+ if (!allowed.includes(arg)) {
338
+ return parseError(`Unknown flag '${arg}' for '${verbLabel(verb)}'.`);
339
+ }
340
+ // `--field` is the one repeatable flag; everything else is a singleton.
341
+ if (arg !== "--field") {
342
+ if (seen.has(arg))
343
+ return parseError(`Duplicate flag '${arg}'.`);
344
+ seen.add(arg);
345
+ }
346
+ switch (arg) {
347
+ case "--dry-run":
348
+ options.dryRun = true;
349
+ break;
350
+ case "--json":
351
+ options.json = true;
352
+ break;
353
+ case "--override-retention":
354
+ options.overrideRetention = true;
355
+ break;
356
+ case "--field": {
357
+ const name = rest[i + 1];
358
+ const value = rest[i + 2];
359
+ if (name === undefined || value === undefined) {
360
+ return parseError("--field requires a name and a value.");
361
+ }
362
+ options.fields.push({ name, value });
363
+ i += 2;
364
+ break;
365
+ }
366
+ default: {
367
+ const value = rest[i + 1];
368
+ if (value === undefined)
369
+ return parseError(`${arg} requires a value.`);
370
+ i += 1;
371
+ const assigned = assignFlagValue(options, arg, value);
372
+ if (assigned !== null)
373
+ return parseError(assigned);
374
+ }
375
+ }
376
+ }
377
+ // BAPI-963: `scopes` is repository-scoped and takes NO positional. Rejecting a
378
+ // supplied key rather than ignoring it keeps the promise `--help` makes exact —
379
+ // an operator who types `implement-epic scopes BAPI-1` learns the verb's shape
380
+ // instead of silently getting a listing that ignored their argument.
381
+ if (verb === "scopes") {
382
+ if (epicKey !== undefined) {
383
+ return parseError(`'scopes' takes no <EPIC> key; it lists every scope this repository owns.`);
384
+ }
385
+ }
386
+ else {
387
+ if (epicKey === undefined)
388
+ return parseError(`'${verbLabel(verb)}' requires an <EPIC> key.`);
389
+ if (!CONDUCT_EPIC_KEY_PATTERN.test(epicKey)) {
390
+ return parseError(`Invalid epic key '${epicKey}'. Expected the form PROJ-123.`);
391
+ }
392
+ options.epicKey = epicKey;
393
+ }
394
+ const missing = requiredFlagError(options);
395
+ if (missing !== null)
396
+ return parseError(missing);
397
+ return { status: "ok", options };
398
+ }
399
+ /** Human label for a verb in an error message. */
400
+ function verbLabel(verb) {
401
+ return verb === "checkpoint-set" ? "checkpoint set" : verb;
402
+ }
403
+ /** Apply one value-taking flag, returning an error message or `null`. */
404
+ function assignFlagValue(options, flag, value) {
405
+ switch (flag) {
406
+ case "--tickets": {
407
+ const keys = value
408
+ .split(",")
409
+ .map((k) => k.trim())
410
+ .filter((k) => k.length > 0);
411
+ if (keys.length === 0)
412
+ return "--tickets requires at least one ticket key.";
413
+ for (const key of keys) {
414
+ if (!CONDUCT_EPIC_KEY_PATTERN.test(key)) {
415
+ return `Invalid ticket key '${key}' in --tickets. Expected the form PROJ-123.`;
416
+ }
417
+ }
418
+ if (new Set(keys).size !== keys.length)
419
+ return "--tickets must not repeat a ticket key.";
420
+ options.tickets = keys;
421
+ return null;
422
+ }
423
+ case "--ticket": {
424
+ if (!CONDUCT_EPIC_KEY_PATTERN.test(value)) {
425
+ return `Invalid ticket key '${value}'. Expected the form PROJ-123.`;
426
+ }
427
+ options.ticket = value;
428
+ return null;
429
+ }
430
+ case "--base-branch": {
431
+ const reason = validateBranchName(value);
432
+ if (reason)
433
+ return `Invalid --base-branch value: ${reason}`;
434
+ options.baseBranch = value;
435
+ return null;
436
+ }
437
+ case "--checkpoint-path":
438
+ if (value.trim().length === 0)
439
+ return "--checkpoint-path requires a path.";
440
+ options.checkpointPath = value;
441
+ return null;
442
+ case "--prompt-file":
443
+ if (value.trim().length === 0)
444
+ return "--prompt-file requires a path.";
445
+ options.promptFile = value;
446
+ return null;
447
+ case "--agent":
448
+ if (!CONDUCT_EPIC_AGENTS.includes(value)) {
449
+ return `Unsupported agent '${value}'. Expected one of: ${CONDUCT_EPIC_AGENTS.join(", ")}`;
450
+ }
451
+ options.agent = value;
452
+ return null;
453
+ case "--journal":
454
+ options.journal = value;
455
+ return null;
456
+ case "--scope": {
457
+ // Shape-validated here, before any I/O: a server-minted scope id is a uuid4
458
+ // hex. Refusing a malformed value at the boundary means a typo never becomes
459
+ // an authenticated request naming something arbitrary.
460
+ if (!/^[0-9a-f]{32}$/.test(value)) {
461
+ return `Invalid --scope value '${value}'. Expected a 32-character index-scope id.`;
462
+ }
463
+ options.scope = value;
464
+ return null;
465
+ }
466
+ default:
467
+ return `Unknown flag '${flag}'.`;
468
+ }
469
+ }
470
+ /** Verb-specific required-flag checks. */
471
+ function requiredFlagError(options) {
472
+ if (options.verb === "init" && options.tickets.length === 0) {
473
+ return "init requires --tickets K1,K2,...";
474
+ }
475
+ if (options.verb === "status" && !options.json) {
476
+ return "status requires --json.";
477
+ }
478
+ if (options.verb === "checkpoint-set") {
479
+ if (options.ticket === undefined)
480
+ return "checkpoint set requires --ticket <KEY>.";
481
+ if (options.fields.length === 0 && options.journal === undefined) {
482
+ return "checkpoint set requires at least one --field or --journal.";
483
+ }
484
+ }
485
+ if (options.verb === "spawn") {
486
+ if (options.ticket === undefined)
487
+ return "spawn requires --ticket <KEY>.";
488
+ if (options.promptFile === undefined)
489
+ return "spawn requires --prompt-file <path>.";
490
+ }
491
+ return null;
492
+ }
493
+ // ---------------------------------------------------------------------------
494
+ // Output discipline
495
+ // ---------------------------------------------------------------------------
496
+ /**
497
+ * Emit one success result.
498
+ *
499
+ * With `--json`, stdout carries EXACTLY one JSON object and every human line is
500
+ * diverted to stderr — including the `announced:` line, so a caller parsing
501
+ * stdout never has to strip narration.
502
+ */
503
+ function emitSuccess(deps, json, payload, humanLines = []) {
504
+ if (json) {
505
+ for (const line of humanLines)
506
+ deps.errorLog(line);
507
+ deps.log(JSON.stringify(payload, null, 2));
508
+ }
509
+ else {
510
+ for (const line of humanLines)
511
+ deps.log(line);
512
+ }
513
+ return 0;
514
+ }
515
+ /**
516
+ * Emit one failure. Reasons go to stderr as concise lines; with `--json`, stdout
517
+ * still carries exactly one object, so a caller can parse a failure the same way
518
+ * it parses a success.
519
+ */
520
+ function emitFailure(deps, json, reasons, payload = {}) {
521
+ for (const reason of reasons)
522
+ deps.errorLog(reason);
523
+ if (json) {
524
+ deps.log(JSON.stringify({ ok: false, ...payload, failures: reasons }, null, 2));
525
+ }
526
+ return 1;
527
+ }
528
+ // ---------------------------------------------------------------------------
529
+ // Shared helpers
530
+ // ---------------------------------------------------------------------------
531
+ /** The epic's own branch name. Fixed by contract; never operator-supplied. */
532
+ function epicBranchFor(epicKey) {
533
+ return `epic/${epicKey}`;
534
+ }
535
+ /**
536
+ * Ensure the draft epic-integration PR (BAPI-951), non-fatally. Never throws;
537
+ * returns the formatted, sanctioned-fields-only outcome for the caller's own
538
+ * advisory/announcement line.
539
+ */
540
+ async function ensureEpicIntegrationPrAdvisory(deps, input) {
541
+ const gh = deps.epicIntegrationGh ?? createProductionEpicIntegrationGhRunner();
542
+ try {
543
+ const outcome = await ensureEpicIntegrationPullRequest({
544
+ epicKey: input.epicKey,
545
+ epicBranch: input.epicBranch,
546
+ baseBranch: input.baseBranch,
547
+ command: input.command,
548
+ gh,
549
+ cwd: deps.cwd,
550
+ requestReady: input.requestReady,
551
+ });
552
+ return formatEpicIntegrationPullRequestOutcome(outcome);
553
+ }
554
+ catch {
555
+ return formatEpicIntegrationPullRequestOutcome({ kind: "unavailable", reason: "probe_inconclusive" });
556
+ }
557
+ }
558
+ /** Resolve the checkpoint path from `--checkpoint-path` or the default. */
559
+ function resolveCheckpointPath(deps, repoName, epicKey, override) {
560
+ if (override !== undefined)
561
+ return path.resolve(override);
562
+ return resolveConductEpicCheckpointPath(repoName, epicKey, {
563
+ env: deps.env,
564
+ homedir: deps.homedir,
565
+ });
566
+ }
567
+ /**
568
+ * The lock seams built from the injected deps.
569
+ *
570
+ * Exported so a test can drive concurrent acquisitions through the exact bridge
571
+ * every mutating verb uses — the production default in `lock.ts` is never
572
+ * reached from here, so this bridge is what the mutual-exclusion guarantee
573
+ * actually rests on.
574
+ */
575
+ export function buildConductEpicLockSeams(deps) {
576
+ return {
577
+ readFile: (filePath) => deps.fs.readFile(filePath),
578
+ removeFile: (filePath) => deps.fs.unlink(filePath),
579
+ mkdir: (dirPath, options) => deps.fs.mkdir(dirPath, options),
580
+ isProcessAlive: deps.isProcessAlive,
581
+ // The atomic exclusive-create primitive itself, not an exists-then-write
582
+ // pair: contention must surface as an EEXIST rejection from a single
583
+ // syscall, which is the whole mutual-exclusion signal.
584
+ writeFileExclusive: (filePath, data) => deps.fs.writeFileExclusive(filePath, data, { mode: 0o600 }),
585
+ };
586
+ }
587
+ /** This invocation's lock identity. */
588
+ function lockRequest(deps) {
589
+ return {
590
+ ownerPid: deps.pid,
591
+ host: deps.hostname(),
592
+ acquiredAt: deps.now().toISOString(),
593
+ };
594
+ }
595
+ /**
596
+ * Resolve the repository name for PATH derivation, independent of credentials.
597
+ *
598
+ * Falls back to `"unknown"` only when the identity genuinely cannot be resolved
599
+ * from `BAPI_REPO_NAME` or `.bridge/config` — at which point no checkpoint could
600
+ * have been written under a real name either, so the two agree.
601
+ */
602
+ async function resolveRepoNameForPath(deps) {
603
+ try {
604
+ const result = await deps.resolveRepoName({
605
+ env: deps.env,
606
+ cwd: deps.cwd,
607
+ readFile: (filePath) => deps.fs.readFile(filePath),
608
+ });
609
+ if (result.ok)
610
+ return result.repoName;
611
+ }
612
+ catch {
613
+ /* sanitized: an identity failure degrades to the placeholder below */
614
+ }
615
+ return "unknown";
616
+ }
617
+ /** Resolve Bridge access, or a sanitized reason. Never returns the key. */
618
+ async function resolveAccess(deps) {
619
+ const result = await deps.resolveAccess({
620
+ env: deps.env,
621
+ cwd: deps.cwd,
622
+ homedir: deps.homedir,
623
+ platform: deps.platform,
624
+ readFile: (filePath) => deps.fs.readFile(filePath),
625
+ stat: (filePath) => deps.fs.stat(filePath),
626
+ });
627
+ if (result.ok)
628
+ return { ok: true, access: result.access };
629
+ return { ok: false, error: `Bridge credentials could not be resolved: ${result.error}` };
630
+ }
631
+ /** Run `git` with list args in the repository working directory. */
632
+ function git(deps, args) {
633
+ return runGit(cutProtocolDeps(deps), args);
634
+ }
635
+ /** The strict subset of the pilot's deps the shared cut protocol consumes. */
636
+ function cutProtocolDeps(deps) {
637
+ return {
638
+ runCommand: deps.runCommand,
639
+ cwd: deps.cwd,
640
+ fetchImpl: deps.fetchImpl,
641
+ errorLog: deps.errorLog,
642
+ };
643
+ }
644
+ function isRecord(value) {
645
+ return typeof value === "object" && value !== null && !Array.isArray(value);
646
+ }
647
+ /** Seconds between two ISO timestamps and `now`, clamped at zero. */
648
+ function elapsedSeconds(from, now) {
649
+ if (from === null)
650
+ return null;
651
+ const start = Date.parse(from);
652
+ if (Number.isNaN(start))
653
+ return null;
654
+ return Math.max(0, Math.floor((now.getTime() - start) / 1000));
655
+ }
656
+ /** The in-flight ticket: the FIRST entry that is not `done`. */
657
+ function inFlightTicket(checkpoint) {
658
+ return checkpoint.tickets.find((ticket) => ticket.status !== "done") ?? null;
659
+ }
660
+ // ---------------------------------------------------------------------------
661
+ // The published-build identity (BAPI-873)
662
+ // ---------------------------------------------------------------------------
663
+ /**
664
+ * Hard bound on launching the published package to read its identity. `npx` may
665
+ * have to download a tarball on a cold cache, so this is generous relative to
666
+ * the registry lookup — but it is a bound, because a wedged launch must never
667
+ * stall `init` indefinitely.
668
+ */
669
+ export const PUBLISHED_IDENTITY_TIMEOUT_MS = 120_000;
670
+ /** The identity shape `--version` emits: 12 lowercase hex, optionally `-dirty`. */
671
+ const PUBLISHED_IDENTITY_PATTERN = /^commit: ([0-9a-f]{12})(-dirty)?$/;
672
+ /** The sentinel a build with no git metadata reports. */
673
+ const PUBLISHED_IDENTITY_UNKNOWN = "unknown";
674
+ /**
675
+ * Opt-out for the published-identity read, for spawned integration children
676
+ * ONLY (BAPI-944).
677
+ *
678
+ * The required `mcp-integration` CI lane spawns real `implement-epic init`
679
+ * children, and `readPublishedBuildIdentity` otherwise resolves the npm
680
+ * `latest` dist-tag and launches it through `npx` — a live registry
681
+ * dependency inside a lane that is supposed to be hermetic. Naming follows
682
+ * `INSTALL_REEXEC_SENTINEL` (`install-reexec.ts`); the strict-enabled-value
683
+ * convention follows `MCP_INTEGRATION` (`integration/harness.ts`). Kept
684
+ * distinct from `MCP_INTEGRATION` deliberately: that flag gates which test
685
+ * files run at all, this one gates a single dependency inside them, and
686
+ * conflating the two would make it impossible to run the integration suite
687
+ * against a real registry when that is exactly what is under test.
688
+ */
689
+ export const CONDUCT_EPIC_PUBLISHED_IDENTITY_FIXTURE_ENV = "BAPI_CONDUCTOR_PUBLISHED_IDENTITY_FIXTURE";
690
+ /**
691
+ * The static clean identity returned when the fixture seam is enabled.
692
+ *
693
+ * Non-secret and independent of the current checkout by construction — its
694
+ * commit is not expected to be present in any local history, so it exercises
695
+ * the SAME "commit unavailable locally" advisory path a real cold checkout
696
+ * would hit, rather than a synthesized pass.
697
+ */
698
+ const CONDUCT_EPIC_PUBLISHED_IDENTITY_FIXTURE = {
699
+ kind: "known",
700
+ version: "0.0.0-fixture",
701
+ commit: "deadbeefcafe",
702
+ dirty: false,
703
+ };
704
+ /** Human wording for each unavailable category, for the fail-open advisory. */
705
+ export function describePublishedIdentityReason(reason) {
706
+ switch (reason) {
707
+ case "registry_unreadable":
708
+ return "the npm registry could not be read";
709
+ case "launch_failed":
710
+ return "the published package could not be launched";
711
+ case "unreadable_output":
712
+ return "the published package reported no readable build identity";
713
+ case "identity_unknown":
714
+ return "the published build reports an unknown build commit";
715
+ }
716
+ }
717
+ /**
718
+ * Read the commit identity embedded in the LATEST PUBLISHED package.
719
+ *
720
+ * Two bounded steps: resolve the exact latest version through the shared
721
+ * registry lookup, then run THAT EXACT VERSION with `--version`. The exactness
722
+ * matters — invoking a moving `@latest` would read whatever the registry served
723
+ * at that instant, so the version reported and the version inspected could
724
+ * differ, and the gate would be comparing an identity to the wrong build.
725
+ *
726
+ * Every failure is `unavailable`, never a mismatch: not knowing what was
727
+ * published is a different fact from knowing it is wrong, and only the second
728
+ * may block a run.
729
+ */
730
+ export async function readPublishedBuildIdentity(deps) {
731
+ // BAPI-944: the fixture short-circuits BOTH the registry resolver and the
732
+ // `npx` probe below — checked before either is touched, so a spawned
733
+ // integration child with the sentinel set never reaches the network or the
734
+ // package manager.
735
+ if (deps.publishedIdentityFixture !== undefined) {
736
+ return deps.publishedIdentityFixture;
737
+ }
738
+ const resolveVersion = deps.resolveLatestPublishedVersion ?? (() => fetchLatestVersion({ fetch: deps.fetchImpl }));
739
+ let version;
740
+ try {
741
+ version = await resolveVersion();
742
+ }
743
+ catch {
744
+ return { kind: "unavailable", reason: "registry_unreadable" };
745
+ }
746
+ if (typeof version !== "string" || version.trim().length === 0) {
747
+ return { kind: "unavailable", reason: "registry_unreadable" };
748
+ }
749
+ const resolvedVersion = version.trim();
750
+ let probe;
751
+ try {
752
+ probe = await deps.runCommand("npx", ["-y", `${MCP_PACKAGE_NAME}@${resolvedVersion}`, "--version"], { cwd: deps.cwd, timeoutMs: PUBLISHED_IDENTITY_TIMEOUT_MS });
753
+ }
754
+ catch {
755
+ return { kind: "unavailable", reason: "launch_failed" };
756
+ }
757
+ if (!probe || probe.exitCode !== 0) {
758
+ return { kind: "unavailable", reason: "launch_failed" };
759
+ }
760
+ const lines = String(probe.stdout ?? "")
761
+ .split("\n")
762
+ .map((line) => line.trim())
763
+ .filter((line) => line.length > 0);
764
+ // The first line is the semver contract `--version` has always emitted. It
765
+ // must be the version we asked for, or the output does not describe the build
766
+ // this reader resolved.
767
+ if (lines[0] !== resolvedVersion) {
768
+ return { kind: "unavailable", reason: "unreadable_output" };
769
+ }
770
+ const commitLine = lines.slice(1).find((line) => line.startsWith("commit:"));
771
+ if (commitLine === undefined) {
772
+ return { kind: "unavailable", reason: "unreadable_output" };
773
+ }
774
+ if (commitLine === `commit: ${PUBLISHED_IDENTITY_UNKNOWN}`) {
775
+ return { kind: "unavailable", reason: "identity_unknown" };
776
+ }
777
+ const match = PUBLISHED_IDENTITY_PATTERN.exec(commitLine);
778
+ if (match === null) {
779
+ return { kind: "unavailable", reason: "unreadable_output" };
780
+ }
781
+ return { kind: "known", version: resolvedVersion, commit: match[1], dirty: match[2] !== undefined };
782
+ }
783
+ /**
784
+ * Decide whether the PUBLISHED build carries the code this epic will be cut at.
785
+ *
786
+ * The gate is expressed as CONTAINMENT, not as a version floor and not as an
787
+ * exact-commit match. "The published build is at least as new as the commit we
788
+ * are conducting" is the property that actually matters, and it is the property
789
+ * a version number could never express: the same semver spanned three different
790
+ * contents, which is why the old floor was unverifiable.
791
+ *
792
+ * Blocking and fail-open are separated deliberately. Knowing the published build
793
+ * is wrong blocks. NOT knowing what was published — a registry outage, a cold
794
+ * npx launch that failed, a build with no git metadata — is an advisory, because
795
+ * a network problem must never stop a run.
796
+ */
797
+ export async function evaluatePublishGate(deps, expectedCommitSha) {
798
+ if (expectedCommitSha === null) {
799
+ // The canonical-index check already recorded its own failure; adding a
800
+ // second one for the same root cause only pads the report.
801
+ return {
802
+ failures: [],
803
+ advisories: [
804
+ "advisory: the publish gate was not evaluated because no canonical indexed commit is available to check against.",
805
+ ],
806
+ };
807
+ }
808
+ const expected = normalizeCommitSha(expectedCommitSha);
809
+ if (expected === null) {
810
+ return {
811
+ failures: [
812
+ "The publish gate cannot be evaluated: the expected commit is not a full 40-character SHA. " +
813
+ "Refusing rather than comparing an arbitrary prefix.",
814
+ ],
815
+ advisories: [],
816
+ };
817
+ }
818
+ const identity = await readPublishedBuildIdentity(deps);
819
+ if (identity.kind === "unavailable") {
820
+ return {
821
+ failures: [],
822
+ advisories: [
823
+ `advisory: the publish gate could not be verified — ${describePublishedIdentityReason(identity.reason)}. ` +
824
+ `Initialization is continuing; the published ${MCP_PACKAGE_NAME} build was NOT confirmed to contain ${expected}.`,
825
+ ],
826
+ };
827
+ }
828
+ if (identity.dirty) {
829
+ return {
830
+ failures: [
831
+ `The published ${MCP_PACKAGE_NAME}@${identity.version} reports build commit ${identity.commit}-dirty. ` +
832
+ "A dirty build carries content that no commit represents, so it cannot be verified to contain " +
833
+ `${expected}. Publish a build from a clean checkout.`,
834
+ ],
835
+ advisories: [],
836
+ };
837
+ }
838
+ // The published SHA must be an object THIS checkout knows about before any
839
+ // ancestry claim is possible. It is a short SHA, so it cannot be fetched by
840
+ // name — an unresolvable one is "cannot verify", never "wrong".
841
+ const present = await git(deps, ["rev-parse", "--verify", "--quiet", `${identity.commit}^{commit}`]);
842
+ if (present.exitCode !== 0) {
843
+ return {
844
+ failures: [],
845
+ advisories: [
846
+ `advisory: the published ${MCP_PACKAGE_NAME}@${identity.version} build commit ${identity.commit} ` +
847
+ "is not present in this checkout, so the publish gate could not be verified. " +
848
+ `Initialization is continuing; fetch origin and confirm that build contains ${expected}.`,
849
+ ],
850
+ };
851
+ }
852
+ const contains = await git(deps, ["merge-base", "--is-ancestor", expected, identity.commit]);
853
+ if (contains.exitCode === 0)
854
+ return { failures: [], advisories: [] };
855
+ if (contains.exitCode === 1) {
856
+ return {
857
+ failures: [
858
+ `The published ${MCP_PACKAGE_NAME}@${identity.version} was built from ${identity.commit}, ` +
859
+ `which does not contain ${expected} — the canonical indexed commit this epic is cut at. ` +
860
+ "Publish a build containing that commit before initializing. Since BAPI-945 the " +
861
+ "publisher is CI, not a laptop: from a clean, current `main` checkout run " +
862
+ "`cd mcp_server && npm version patch`, then `git push origin main` and " +
863
+ '`git push origin "mcp-server/v<version>"` — that tag push triggers the ' +
864
+ "clean-room release workflow. There is deliberately no --force or --override here.",
865
+ ],
866
+ advisories: [],
867
+ };
868
+ }
869
+ return {
870
+ failures: [],
871
+ advisories: [
872
+ `advisory: the publish gate could not be verified — the ancestry of published build commit ` +
873
+ `${identity.commit} could not be determined locally. Initialization is continuing.`,
874
+ ],
875
+ };
876
+ }
877
+ /**
878
+ * Run every independent `init` check and ACCUMULATE the failures.
879
+ *
880
+ * The accumulation is the feature. A first-failure-wins preflight turns a
881
+ * five-problem environment into five invocations, each revealing one problem,
882
+ * and the operator fixes them in the order the code happens to check rather than
883
+ * the order that matters. Checks that genuinely depend on an earlier result
884
+ * (everything needing credentials, everything needing a base branch) are skipped
885
+ * with their own recorded failure rather than crashing.
886
+ *
887
+ * NOTHING here mutates: no push, no repoint, no checkpoint write, no lock, and
888
+ * no supervisor-config PUT. The one write this function used to perform — turning
889
+ * `auto_merge_enabled` on — is returned as {@link ConductEpicInitPreflight.pendingSupervisorConfig}
890
+ * for the caller to execute on the success path, because a preflight that
891
+ * accumulates failures must not have already changed a project-wide default by
892
+ * the time it refuses.
893
+ */
894
+ export async function collectConductEpicInitPreflight(deps, options) {
895
+ const failures = [];
896
+ const announcements = [];
897
+ const advisories = [];
898
+ const epicBranch = epicBranchFor(options.epicKey);
899
+ let pendingSupervisorConfig = null;
900
+ // (1) gh authentication.
901
+ try {
902
+ const gh = await deps.runGh(["auth", "status"], { cwd: deps.cwd });
903
+ if (!gh.ok)
904
+ failures.push("gh is not authenticated. Run `gh auth login`.");
905
+ }
906
+ catch {
907
+ failures.push("gh could not be run. Install the GitHub CLI and run `gh auth login`.");
908
+ }
909
+ // (2) Worktrunk, honoring BAPI_WORKTRUNK_BIN.
910
+ const worktrunk = resolveWorktrunkBinary(deps.platform, deps.env);
911
+ try {
912
+ const probe = await deps.runCommand(worktrunk, ["--version"], { cwd: deps.cwd });
913
+ if (probe.exitCode !== 0) {
914
+ failures.push(`Worktrunk ('${worktrunk}') is not available on PATH.`);
915
+ }
916
+ }
917
+ catch {
918
+ failures.push(`Worktrunk ('${worktrunk}') is not available on PATH.`);
919
+ }
920
+ // (3) Bridge credentials.
921
+ const accessResult = await resolveAccess(deps);
922
+ const access = accessResult.ok ? accessResult.access : null;
923
+ if (!accessResult.ok)
924
+ failures.push(accessResult.error);
925
+ let baseBranch = options.baseBranch ?? null;
926
+ let baseSha = null;
927
+ let cutCommitSha = null;
928
+ let epicBranchAlreadyAtCut = false;
929
+ if (access !== null) {
930
+ // (4) auto_merge_enabled, and (5) a non-vacuous required-check set.
931
+ const readiness = await getConductorReadiness(access, deps.fetchImpl);
932
+ if (!readiness.ok) {
933
+ failures.push(`Conductor readiness could not be read: ${readiness.error}`);
934
+ }
935
+ else if (!readiness.value.supervisor.auto_merge_enabled) {
936
+ const current = await getEffectiveSupervisorConfig(access, deps.fetchImpl);
937
+ if (!current.ok) {
938
+ failures.push(`auto_merge_enabled is not set and the effective supervisor config could not be read: ${current.error}`);
939
+ }
940
+ else {
941
+ // Wholesale upsert: copy every field, flip exactly one. Omitting a field
942
+ // would reset it to the request model's default.
943
+ const body = {
944
+ teardown_enabled: current.value.teardown_enabled,
945
+ auto_rereview_enabled: current.value.auto_rereview_enabled,
946
+ max_remediation_attempts: current.value.max_remediation_attempts,
947
+ max_remediation_no_progress_attempts: current.value.max_remediation_no_progress_attempts,
948
+ worker_liveness_window_seconds: current.value.worker_liveness_window_seconds,
949
+ auto_merge_enabled: true,
950
+ merge_approval_required: current.value.merge_approval_required,
951
+ };
952
+ if (options.dryRun) {
953
+ announcements.push(`announced: would enable auto_merge_enabled on the ${access.repoName} project defaults (--dry-run: not sent).`);
954
+ }
955
+ else {
956
+ // Deferred, not sent. The success announcement is added by
957
+ // `runConductEpicInit` only after the PUT actually succeeds, so the
958
+ // `announced:` line never claims a settings change that did not happen.
959
+ pendingSupervisorConfig = body;
960
+ }
961
+ }
962
+ }
963
+ // (5) Required checks. `resolveCiChecks` is called EXACTLY ONCE regardless of
964
+ // the readiness shortcut, because its side effect — warming the
965
+ // `poll-ci-checks` cache — is what makes the first `status` poll answer at
966
+ // all. Skipping it when readiness already looks healthy is the bug that
967
+ // makes tick 1 report `available:false` for no reason.
968
+ const resolved = await resolveCiChecks(access, undefined, deps.fetchImpl);
969
+ const resolvedRequired = requiredCheckNamesFromResolve(resolved.ok ? resolved.value : null);
970
+ if (!readiness.ok) {
971
+ // Already recorded above; nothing further can be concluded.
972
+ }
973
+ else if (readiness.value.supervisor.required_checks_empty) {
974
+ if (!resolved.ok) {
975
+ failures.push(`No required CI checks are configured and resolve-ci-checks failed: ${resolved.error}`);
976
+ }
977
+ else if (resolvedRequired.length === 0) {
978
+ failures.push("No required CI checks are configured and resolve-ci-checks returned none. " +
979
+ "A done gate with an empty required set passes vacuously.");
980
+ }
981
+ }
982
+ // (6) No active server-side epic run for this key.
983
+ const runState = await getEpicRunState(access, options.epicKey, deps.fetchImpl);
984
+ if (runState.ok) {
985
+ const status = runState.value.epic_run?.status;
986
+ if (status !== "done" && status !== "abandoned") {
987
+ failures.push(`A server-side epic run for ${options.epicKey} is ${String(status)}. ` +
988
+ "implement-epic will not drive an epic the v2 reconciler is already conducting.");
989
+ }
990
+ }
991
+ else if (runState.status !== 404) {
992
+ failures.push(`The epic-run state for ${options.epicKey} could not be read: ${runState.error}`);
993
+ }
994
+ // (10) BAPI-847: there is no repository-wide index-branch override to check
995
+ // any more. An epic no longer takes the repository's index away from anyone —
996
+ // it gets its OWN index scope — so two epics running at once is an ordinary
997
+ // state rather than a conflict a preflight has to detect. The default-base
998
+ // resolution below therefore never reads a stored "original" branch: nothing
999
+ // was ever repointed, so the repository's configured base IS the original.
1000
+ // (11) The canonical index must have a usable commit (BAPI-843). This is the
1001
+ // check that inverts the cut order: without a `succeeded` canonical parse
1002
+ // publishing a commit, there is no commit to cut at, and cutting at the base
1003
+ // tip instead is exactly the behavior this replaces. The failure text names
1004
+ // the fix an operator can actually perform.
1005
+ const parseStatus = await getParseStatus(access, deps.fetchImpl);
1006
+ if (!parseStatus.ok) {
1007
+ failures.push(`The canonical parse status could not be read: ${parseStatus.error}`);
1008
+ }
1009
+ else if (parseStatus.value.status !== "succeeded") {
1010
+ failures.push(`The canonical index for ${access.repoName} has no successful parse ` +
1011
+ `(status: ${String(parseStatus.value.status)}). Parse the repository first.`);
1012
+ }
1013
+ else {
1014
+ const indexed = normalizeCommitSha(parseStatus.value.indexed_commit_sha);
1015
+ if (indexed === null) {
1016
+ failures.push(`The canonical index for ${access.repoName} published no commit for its ` +
1017
+ "last successful parse, so there is no commit to cut at. " +
1018
+ "Parse the repository first.");
1019
+ }
1020
+ else {
1021
+ cutCommitSha = indexed;
1022
+ }
1023
+ }
1024
+ // (8) default base, continued: the configured base branch, then `main`.
1025
+ if (baseBranch === null) {
1026
+ const configured = await getConfigFieldBaseBranch(access, deps.fetchImpl);
1027
+ if (configured.ok && configured.value.base_branch !== null) {
1028
+ baseBranch = configured.value.base_branch;
1029
+ }
1030
+ }
1031
+ }
1032
+ if (baseBranch === null)
1033
+ baseBranch = "main";
1034
+ const branchReason = validateBranchName(baseBranch);
1035
+ if (branchReason) {
1036
+ failures.push(`The resolved base branch is unusable: ${branchReason}`);
1037
+ }
1038
+ else {
1039
+ // (8) The base is still fetched — local git needs its object history, and the
1040
+ // cut commit is almost always reachable from it — but the base tip is NO
1041
+ // LONGER the branch source (BAPI-843). It is reported for context only.
1042
+ const fetched = await git(deps, ["fetch", "origin", baseBranch]);
1043
+ if (fetched.exitCode !== 0) {
1044
+ failures.push(`git fetch origin ${baseBranch} failed.`);
1045
+ }
1046
+ const tip = await git(deps, ["rev-parse", `refs/remotes/origin/${baseBranch}`]);
1047
+ baseSha = tip.exitCode === 0 ? firstLine(tip) : null;
1048
+ if (baseSha === null) {
1049
+ failures.push(`origin/${baseBranch} does not exist after fetching.`);
1050
+ }
1051
+ if (cutCommitSha !== null) {
1052
+ // (12) The EXACT cut object must be resolvable locally, because `init`
1053
+ // pushes it by SHA. The base fetch usually brings it along; when it did
1054
+ // not — the index covers a commit that is no longer an ancestor of the
1055
+ // base tip — one targeted, NON-MUTATING fetch of that SHA is attempted (it
1056
+ // updates no ref, creates no branch, and checks nothing out). Failing here
1057
+ // is deliberate: it happens before the cut protocol, so nothing has been
1058
+ // held, pushed, or recorded.
1059
+ const present = await git(deps, ["rev-parse", "--verify", "--quiet", `${cutCommitSha}^{commit}`]);
1060
+ if (present.exitCode !== 0) {
1061
+ await git(deps, ["fetch", "origin", cutCommitSha]);
1062
+ const retry = await git(deps, ["rev-parse", "--verify", "--quiet", `${cutCommitSha}^{commit}`]);
1063
+ if (retry.exitCode !== 0) {
1064
+ failures.push(`The canonical indexed commit ${cutCommitSha} could not be resolved locally ` +
1065
+ `even after fetching it from origin. Fetch it manually, or re-parse ${access?.repoName ?? "the repository"}.`);
1066
+ cutCommitSha = null;
1067
+ }
1068
+ }
1069
+ }
1070
+ // (13) BAPI-873: the publish gate. Evaluated HERE — after the cut commit is
1071
+ // known and proven present locally, and still before anything mutates —
1072
+ // because the gate's question is whether the PUBLISHED package (the one
1073
+ // `start-tickets` spawns for every worker) already carries the code this
1074
+ // epic is cut at. A readable mismatch joins `failures` so it is reported
1075
+ // alongside every other readiness problem; an unreadable published identity
1076
+ // becomes an advisory and the run continues.
1077
+ const publishGate = await evaluatePublishGate(deps, cutCommitSha);
1078
+ failures.push(...publishGate.failures);
1079
+ advisories.push(...publishGate.advisories);
1080
+ // (9) `epic/<EPIC>` must be absent on origin, or already at exactly the
1081
+ // canonical indexed commit. An epic branch sitting at ANY other commit still
1082
+ // fails closed — including the base tip, which is no longer special.
1083
+ const existing = await git(deps, ["ls-remote", "--heads", "origin", `refs/heads/${epicBranch}`]);
1084
+ if (existing.exitCode !== 0) {
1085
+ failures.push(`git ls-remote could not read origin/${epicBranch}.`);
1086
+ }
1087
+ else {
1088
+ const existingSha = lsRemoteSha(existing);
1089
+ if (existingSha !== null) {
1090
+ if (cutCommitSha !== null && existingSha === cutCommitSha) {
1091
+ epicBranchAlreadyAtCut = true;
1092
+ }
1093
+ else {
1094
+ failures.push(`origin/${epicBranch} already exists at ${existingSha}, which is not the ` +
1095
+ `canonical indexed commit${cutCommitSha ? ` ${cutCommitSha}` : ""}. ` +
1096
+ "Delete it or finish the previous run before re-initializing.");
1097
+ }
1098
+ }
1099
+ }
1100
+ }
1101
+ // (7) The lock must be free or recoverably dead. Inspected READ-ONLY here;
1102
+ // acquisition happens only after every check passes.
1103
+ const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
1104
+ const lockState = await inspectConductEpicLock(resolveConductEpicLockPath(checkpointPath), lockRequest(deps), buildConductEpicLockSeams(deps));
1105
+ if (lockState.kind === "live-foreign") {
1106
+ failures.push(`The epic lock is held by live process ${lockState.owner.owner_pid} on ${lockState.owner.host}.`);
1107
+ }
1108
+ else if (lockState.kind === "remote-host") {
1109
+ failures.push(`The epic lock was taken on host ${lockState.owner.host} and cannot be recovered here.`);
1110
+ }
1111
+ else if (lockState.kind === "unknown") {
1112
+ failures.push(`The epic lock is unusable: ${lockState.reason}`);
1113
+ }
1114
+ return {
1115
+ failures,
1116
+ announcements,
1117
+ advisories,
1118
+ access,
1119
+ baseBranch,
1120
+ baseSha,
1121
+ cutCommitSha,
1122
+ epicBranchAlreadyAtCut,
1123
+ pendingSupervisorConfig,
1124
+ };
1125
+ }
1126
+ /** Extract required check NAMES from a `resolve-ci-checks` soft envelope. */
1127
+ function requiredCheckNamesFromResolve(value) {
1128
+ if (!isRecord(value))
1129
+ return [];
1130
+ const detail = value["detail"];
1131
+ if (!isRecord(detail))
1132
+ return [];
1133
+ const checks = detail["checks"];
1134
+ if (!Array.isArray(checks))
1135
+ return [];
1136
+ const names = [];
1137
+ for (const check of checks) {
1138
+ if (!isRecord(check))
1139
+ continue;
1140
+ if (check["required"] !== true)
1141
+ continue;
1142
+ const name = check["name"];
1143
+ if (typeof name === "string" && name.trim().length > 0)
1144
+ names.push(name.trim());
1145
+ }
1146
+ return names;
1147
+ }
1148
+ /**
1149
+ * Drive the scope from a recorded cut to `ready`, or report why it did not.
1150
+ *
1151
+ * Schedules the bootstrap (seed + verifying parse) and then POLLS the control
1152
+ * plane, because readiness is a server-side fact this CLI observes rather than
1153
+ * one it can conclude from its own request succeeding.
1154
+ *
1155
+ * Each terminal state maps to its own operator instruction, and the distinction
1156
+ * matters: `canonical_index_advanced` means re-run `init` (the cut protocol
1157
+ * re-drives at the newer commit), while a verification failure means the seed
1158
+ * itself is wrong and re-cutting would just reproduce it.
1159
+ */
1160
+ async function driveIndexScopeBootstrap(deps, access, scopeId, options) {
1161
+ const scheduled = await bootstrapIndexScope(access, { scopeId }, deps.fetchImpl);
1162
+ if (!scheduled.ok) {
1163
+ return { ok: false, failures: [`The index scope could not be seeded: ${scheduled.error}`] };
1164
+ }
1165
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1166
+ // BAPI-963. The seed printed NOTHING for ~30 minutes during the BAPI-943 pilot:
1167
+ // no output, no checkpoint, and a frozen `epic_shadow_index.updated_at`, so a
1168
+ // healthy seed and a wedge looked identical from outside. Because the seed
1169
+ // copies the canonical index wholesale rather than re-parsing, no
1170
+ // `repository_parse_runs` row appears either, removing the last progress
1171
+ // signal. The notice and the per-poll heartbeat below are the fix.
1172
+ //
1173
+ // The MECHANISM (elapsed computation, the line shape, the window description)
1174
+ // lives at the shared `cut-protocol.ts` seam that `setup-epic` also drives, so
1175
+ // v2 can adopt the same progress observation; the RENDERING stays here, on the
1176
+ // pilot's own stderr advisory channel, which is what keeps stdout exactly one
1177
+ // JSON object under `--json`.
1178
+ const startedAtMs = deps.now().getTime();
1179
+ deps.errorLog(describeScopeBootstrapWindow(SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, SCOPE_BOOTSTRAP_MAX_POLLS));
1180
+ let lastState = "unknown";
1181
+ for (let poll = 0; poll < SCOPE_BOOTSTRAP_MAX_POLLS; poll += 1) {
1182
+ await sleep(SCOPE_BOOTSTRAP_POLL_INTERVAL_MS);
1183
+ const status = await getIndexScopeStatus(access, scopeId, deps.fetchImpl);
1184
+ const elapsedMs = deps.now().getTime() - startedAtMs;
1185
+ if (!status.ok) {
1186
+ // A transient read failure is not a verdict: keep polling and let the
1187
+ // bound below be the thing that gives up. The heartbeat reports the fixed
1188
+ // `unreadable` label rather than the raw error, which stays out of a line
1189
+ // that repeats every interval.
1190
+ deps.errorLog(formatScopeBootstrapHeartbeat(elapsedMs, SCOPE_BOOTSTRAP_UNREADABLE_STATE));
1191
+ lastState = `unreadable (${status.error})`;
1192
+ continue;
1193
+ }
1194
+ deps.errorLog(formatScopeBootstrapHeartbeat(elapsedMs, status.value.lifecycle_state));
1195
+ lastState = status.value.lifecycle_state;
1196
+ if (status.value.lifecycle_state === "ready") {
1197
+ if (status.value.indexed_commit_sha !== null &&
1198
+ status.value.indexed_commit_sha === status.value.cut_commit_sha) {
1199
+ return { ok: true, failures: [] };
1200
+ }
1201
+ // `ready` is the server's verdict, and the server only promotes a scope
1202
+ // whose watermark matches. Disagreeing here would mean the control plane
1203
+ // contradicted itself, which is worth refusing rather than proceeding.
1204
+ return {
1205
+ ok: false,
1206
+ failures: [
1207
+ `Index scope ${scopeId} reports ready but its indexed commit ` +
1208
+ `(${status.value.indexed_commit_sha ?? "none"}) is not the cut commit ` +
1209
+ `(${status.value.cut_commit_sha ?? "none"}).`,
1210
+ ],
1211
+ };
1212
+ }
1213
+ if (status.value.lifecycle_state === "failed") {
1214
+ const reason = status.value.last_error ?? "unknown";
1215
+ if (reason === "canonical_index_advanced") {
1216
+ return {
1217
+ ok: false,
1218
+ failures: [
1219
+ `The canonical index advanced before the seed could run, so the scope was not seeded. ` +
1220
+ `Delete origin/${epicBranchFor(options.epicKey)} and re-run init to cut at the newer commit.`,
1221
+ ],
1222
+ };
1223
+ }
1224
+ return {
1225
+ ok: false,
1226
+ failures: [
1227
+ `Index scope ${scopeId} failed verification (${reason}). ` +
1228
+ "The epic branch and its recorded cut are intact; re-run init to re-drive verification.",
1229
+ ],
1230
+ };
1231
+ }
1232
+ }
1233
+ return {
1234
+ ok: false,
1235
+ failures: [
1236
+ `TIMED OUT: index scope ${scopeId} did not become ready within the bootstrap window ` +
1237
+ `(last observed state: ${lastState}). Re-run init to resume verification.`,
1238
+ ],
1239
+ };
1240
+ }
1241
+ /**
1242
+ * `implement-epic init` — the only verb that provisions.
1243
+ *
1244
+ * Refuses a second run outright: an existing checkpoint means an epic is already
1245
+ * under way, and re-initializing would discard its ticket statuses and counters.
1246
+ * That refusal deliberately applies to a CORRUPT existing checkpoint too — the
1247
+ * bytes may be all that is left of a half-finished run, and overwriting them as
1248
+ * "repair" is worse than making a human look.
1249
+ */
1250
+ export async function runConductEpicInit(deps, options) {
1251
+ // Credential resolution is the PREFLIGHT's job (check 3). Probing here as well
1252
+ // would resolve the credential twice and report a failure the preflight is
1253
+ // about to report properly, so `init` reads the access it established instead.
1254
+ const repoName = await resolveRepoNameForPath(deps);
1255
+ const checkpointPath = resolveCheckpointPath(deps, repoName, options.epicKey, options.checkpointPath);
1256
+ const epicBranch = epicBranchFor(options.epicKey);
1257
+ const existing = await readConductEpicCheckpoint(checkpointPath, deps.fs);
1258
+ if (existing.kind !== "missing") {
1259
+ return emitFailure(deps, options.json, [
1260
+ `${options.epicKey} is already initialized: a checkpoint exists at ${checkpointPath}. ` +
1261
+ "Nothing was changed.",
1262
+ ], { epic_key: options.epicKey, checkpoint_path: checkpointPath });
1263
+ }
1264
+ const preflight = await collectConductEpicInitPreflight(deps, options);
1265
+ // BAPI-873: fail-open advisories are reported before the outcome is decided,
1266
+ // on stderr, on both paths — an advisory that only printed on failure would
1267
+ // let a run proceed silently past an unverified publish gate.
1268
+ for (const line of preflight.advisories)
1269
+ deps.errorLog(line);
1270
+ if (preflight.failures.length > 0) {
1271
+ for (const line of preflight.announcements)
1272
+ deps.errorLog(line);
1273
+ return emitFailure(deps, options.json, preflight.failures, {
1274
+ epic_key: options.epicKey,
1275
+ checkpoint_path: checkpointPath,
1276
+ });
1277
+ }
1278
+ const access = preflight.access;
1279
+ if (access === null ||
1280
+ preflight.baseBranch === null ||
1281
+ preflight.baseSha === null ||
1282
+ preflight.cutCommitSha === null) {
1283
+ // Unreachable: any of these being absent records a failure above. Guarded so
1284
+ // a future edit cannot turn a missing precondition into a push — in
1285
+ // particular a missing `cutCommitSha`, which would otherwise be a push at
1286
+ // `undefined`.
1287
+ return emitFailure(deps, options.json, ["init preflight completed without a usable plan."], {
1288
+ epic_key: options.epicKey,
1289
+ });
1290
+ }
1291
+ const cutCommitSha = preflight.cutCommitSha;
1292
+ // Starts as the preflight's own lines (which in `--dry-run` already include the
1293
+ // would-enable notice) and grows by at most the one success line below.
1294
+ const announcements = [...preflight.announcements];
1295
+ // A function, not a value: the deterministic plan-line ordering is fixed, but
1296
+ // the trailing announcements are only final once the deferred PUT has settled.
1297
+ const describePlan = () => [
1298
+ `epic: ${options.epicKey}`,
1299
+ `repo: ${access.repoName}`,
1300
+ // The base branch and the cut commit are reported SEPARATELY and never
1301
+ // conflated: the base is context (and the object history git needs), the cut
1302
+ // is the commit the epic actually starts from.
1303
+ `base: ${preflight.baseBranch} @ ${preflight.baseSha}`,
1304
+ `cut: ${cutCommitSha} (canonical indexed commit)`,
1305
+ `branch: ${epicBranch}${preflight.epicBranchAlreadyAtCut ? " (already at the cut commit)" : ""}`,
1306
+ `tickets: ${options.tickets.join(", ")}`,
1307
+ `checkpoint: ${checkpointPath}`,
1308
+ ...announcements,
1309
+ ];
1310
+ if (options.dryRun) {
1311
+ return emitSuccess(deps, options.json, {
1312
+ ok: true,
1313
+ dry_run: true,
1314
+ epic_key: options.epicKey,
1315
+ epic_branch: epicBranch,
1316
+ base_branch: preflight.baseBranch,
1317
+ base_sha: preflight.baseSha,
1318
+ cut_commit_sha: cutCommitSha,
1319
+ tickets: options.tickets,
1320
+ checkpoint_path: checkpointPath,
1321
+ announcements,
1322
+ }, [
1323
+ "Planned (dry run — nothing was pushed, cut, seeded, repointed, or written):",
1324
+ ...describePlan(),
1325
+ `would open a draft pull request ${epicBranch} → ${preflight.baseBranch} for ` +
1326
+ "conductor-ci / gate (BAPI-951; nothing opened in dry-run)",
1327
+ ]);
1328
+ }
1329
+ // The FIRST durable mutation of the whole verb, deliberately placed here: every
1330
+ // preflight check has passed, and nothing has been pushed, repointed, written,
1331
+ // or locked yet. A failed PUT therefore leaves the environment exactly as `init`
1332
+ // found it.
1333
+ if (preflight.pendingSupervisorConfig !== null) {
1334
+ const put = await putSupervisorConfigDefaults(access, preflight.pendingSupervisorConfig, deps.fetchImpl);
1335
+ if (!put.ok) {
1336
+ return emitFailure(deps, options.json, [`auto_merge_enabled could not be enabled: ${put.error}`], {
1337
+ epic_key: options.epicKey,
1338
+ checkpoint_path: checkpointPath,
1339
+ });
1340
+ }
1341
+ announcements.push(`announced: auto_merge_enabled was OFF and has been enabled on the ${access.repoName} project defaults.`);
1342
+ }
1343
+ // --- The cut protocol (BAPI-843) ----------------------------------------
1344
+ //
1345
+ // Driven by the SHARED exact-cut module (BAPI-850): `cut/begin`, the re-check
1346
+ // of `origin/epic/<EPIC>` under the hold, the exact-SHA push with the
1347
+ // operator's own git, the read-back, `cut/commit`, and the release on every
1348
+ // outcome all happen in `performExactIndexScopeCut`, while the SERVER holds
1349
+ // the canonical repository's parse lock so the commit the index covers cannot
1350
+ // move underneath the ref being created. The pilot creates no `epic_run`, so
1351
+ // it passes no run association.
1352
+ const cutOutcome = await performExactIndexScopeCut(cutProtocolDeps(deps), access, {
1353
+ featureBranch: epicBranch,
1354
+ baseBranch: preflight.baseBranch,
1355
+ candidateCommitSha: cutCommitSha,
1356
+ });
1357
+ if (!cutOutcome.ok) {
1358
+ // `begin` refusals carry the checkpoint path (nothing was leased); every
1359
+ // later refusal names only the epic, exactly as before the extraction.
1360
+ return emitFailure(deps, options.json, cutOutcome.failures, cutOutcome.kind === "begin_refused"
1361
+ ? { epic_key: options.epicKey, checkpoint_path: checkpointPath }
1362
+ : { epic_key: options.epicKey });
1363
+ }
1364
+ // A successful outcome IS the recorded cut: the shared module returns `ok`
1365
+ // only after `cut/commit` accepted the ref, so there is no unrecorded-cut
1366
+ // state to guard against here.
1367
+ const cut = cutOutcome.lease;
1368
+ // --- Seed + verify, only after the hold is released ----------------------
1369
+ const scopeReady = await driveIndexScopeBootstrap(deps, access, cut.scope_id, options);
1370
+ if (!scopeReady.ok) {
1371
+ return emitFailure(deps, options.json, scopeReady.failures, {
1372
+ epic_key: options.epicKey,
1373
+ scope_id: cut.scope_id,
1374
+ checkpoint_path: checkpointPath,
1375
+ });
1376
+ }
1377
+ announcements.push(`announced: index scope ${cut.scope_id} is ready at ${cut.cut_commit_sha}.`);
1378
+ // --- Draft epic-integration PR (BAPI-951) --------------------------------
1379
+ // Only when THIS invocation actually created the branch (`cutOutcome.branchCreated`)
1380
+ // — never on a reused/already-recorded cut, which `init` refuses to reach at all
1381
+ // (an existing checkpoint returns early above). Placed AFTER scope readiness and
1382
+ // BEFORE the checkpoint write, which stays the final mutation.
1383
+ if (cutOutcome.branchCreated) {
1384
+ const formatted = await ensureEpicIntegrationPrAdvisory(deps, {
1385
+ epicKey: options.epicKey,
1386
+ epicBranch,
1387
+ baseBranch: preflight.baseBranch,
1388
+ command: "implement-epic init",
1389
+ });
1390
+ announcements.push(`integration pr: ${JSON.stringify(formatted)}`);
1391
+ }
1392
+ // BAPI-847: `init` used to repoint the REPOSITORY's `base_branch` at the epic
1393
+ // branch here, which is the defect this epic exists to remove — a
1394
+ // repository-wide mutation that every unrelated run then resolved through. The
1395
+ // scope provisioned above already carries the epic branch on its own shadow
1396
+ // config row, so the canonical repository's configuration is never touched and
1397
+ // nothing has to be restored later.
1398
+ //
1399
+ // The checkpoint is still written LAST: it is only correct once the scope has
1400
+ // proven its own coverage.
1401
+ const request = lockRequest(deps);
1402
+ const checkpoint = createInitialConductEpicCheckpoint({
1403
+ epicKey: options.epicKey,
1404
+ repoName: access.repoName,
1405
+ epicBranch,
1406
+ baseBranchOriginal: preflight.baseBranch,
1407
+ // BAPI-844: the server-minted scope from THIS init's cut, recorded so every
1408
+ // later `spawn` declares the scope the epic was actually cut against. It is
1409
+ // stored exactly as returned; nothing local mints, edits, or defaults it.
1410
+ indexScopeId: cut.scope_id,
1411
+ ticketKeys: options.tickets,
1412
+ now: deps.now().toISOString(),
1413
+ lock: { owner_pid: request.ownerPid, host: request.host, acquired_at: request.acquiredAt },
1414
+ });
1415
+ const written = await writeConductEpicCheckpointAtomic(checkpointPath, checkpoint, deps.fs, {
1416
+ skipChmod: deps.platform === "win32",
1417
+ });
1418
+ if (!written.ok) {
1419
+ return emitFailure(deps, options.json, [written.error], { epic_key: options.epicKey });
1420
+ }
1421
+ // The lock is taken LAST: a lock without a checkpoint is a state no verb can
1422
+ // interpret, whereas a checkpoint without a lock is simply an unlocked epic.
1423
+ const lock = await acquireConductEpicLock(resolveConductEpicLockPath(checkpointPath), request, buildConductEpicLockSeams(deps));
1424
+ if (!lock.acquired) {
1425
+ return emitFailure(deps, options.json, [`The epic lock could not be acquired: ${lock.reason}`], {
1426
+ epic_key: options.epicKey,
1427
+ checkpoint_path: checkpointPath,
1428
+ });
1429
+ }
1430
+ return emitSuccess(deps, options.json, {
1431
+ ok: true,
1432
+ epic_key: options.epicKey,
1433
+ epic_branch: epicBranch,
1434
+ base_branch: preflight.baseBranch,
1435
+ base_sha: preflight.baseSha,
1436
+ cut_commit_sha: cutCommitSha,
1437
+ scope_id: cut.scope_id,
1438
+ tickets: options.tickets,
1439
+ checkpoint_path: checkpointPath,
1440
+ lock_path: resolveConductEpicLockPath(checkpointPath),
1441
+ announcements,
1442
+ }, ["Initialized:", ...describePlan()]);
1443
+ }
1444
+ /**
1445
+ * `implement-epic status <EPIC> --json` — the ONLY evidence a tick acts on.
1446
+ *
1447
+ * Two rules shape everything below:
1448
+ *
1449
+ * - **Every probe fails open.** `gh` down, CI unreachable, the review endpoint
1450
+ * unavailable — each leaves its sub-object `null`, adds a `probe_errors` entry,
1451
+ * and the command still exits 0 with a complete object. The loop must be able
1452
+ * to see its own checkpoint during a GitHub outage.
1453
+ * - **The write allowlist is exactly five fields** (`ci_last_poll`,
1454
+ * `last_seen_head`, `last_state_change_at`, a newly discovered
1455
+ * `ticket.branch`, and — since BAPI-963 — an observed `ticket.pr_number`)
1456
+ * plus the normal `updated_at`, alongside the scope's fencing epoch. Everything
1457
+ * else is observational. In particular the checkpoint stores no "expected
1458
+ * head": merge identity always comes from a fresh `pr.head_sha`, and a stored
1459
+ * expectation would be a second source of truth that goes stale.
1460
+ *
1461
+ * `ticket.pr_number` is a SELF-HEAL, not a new source of truth. `status` already
1462
+ * derives the PR from GitHub independently, so the checkpoint field was pure
1463
+ * caller obligation — and when the pilot watcher died during BAPI-943, BAPI-946's
1464
+ * checkpoint still read `pr_number: null` long after PR #1144 had merged, exactly
1465
+ * when the driver had lost track and most needed the durable record.
1466
+ */
1467
+ export async function runConductEpicStatus(deps, options) {
1468
+ const accessProbe = await resolveAccess(deps);
1469
+ const repoName = await resolveRepoNameForPath(deps);
1470
+ const checkpointPath = resolveCheckpointPath(deps, repoName, options.epicKey, options.checkpointPath);
1471
+ const read = await readConductEpicCheckpoint(checkpointPath, deps.fs);
1472
+ if (read.kind === "missing") {
1473
+ // Exactly four keys. BAPI-804 asserts this shape to decide whether to run
1474
+ // `init`, so extra narration here would be a contract break.
1475
+ return emitSuccess(deps, options.json, {
1476
+ ok: true,
1477
+ epic_key: options.epicKey,
1478
+ checkpoint_path: checkpointPath,
1479
+ checkpoint_exists: false,
1480
+ });
1481
+ }
1482
+ if (read.kind !== "ok") {
1483
+ return emitFailure(deps, options.json, [read.error], {
1484
+ epic_key: options.epicKey,
1485
+ checkpoint_path: checkpointPath,
1486
+ });
1487
+ }
1488
+ const checkpoint = read.checkpoint;
1489
+ const now = deps.now();
1490
+ const probeErrors = [];
1491
+ const ticket = inFlightTicket(checkpoint);
1492
+ const allDone = ticket === null;
1493
+ // --- worktree + branch discovery -----------------------------------------
1494
+ let discoveredBranch = ticket?.branch ?? null;
1495
+ let worktreePath = null;
1496
+ let worktreeExists = false;
1497
+ if (ticket !== null) {
1498
+ try {
1499
+ const listed = await git(deps, ["worktree", "list", "--porcelain"]);
1500
+ if (listed.exitCode !== 0) {
1501
+ probeErrors.push({ probe: "worktree", reason: "git worktree list failed" });
1502
+ }
1503
+ else {
1504
+ const found = discoverTicketWorktree(parseGitWorktreePorcelain(listed.stdout), ticket.key, ticket.branch);
1505
+ if (found !== null) {
1506
+ discoveredBranch = found.branch;
1507
+ worktreePath = found.path;
1508
+ try {
1509
+ await deps.fs.stat(found.path);
1510
+ worktreeExists = true;
1511
+ }
1512
+ catch {
1513
+ worktreeExists = false;
1514
+ }
1515
+ }
1516
+ }
1517
+ }
1518
+ catch {
1519
+ probeErrors.push({ probe: "worktree", reason: "git worktree list could not be run" });
1520
+ }
1521
+ }
1522
+ // --- git facts ------------------------------------------------------------
1523
+ let branchHead = null;
1524
+ let workerCommits = 0;
1525
+ if (discoveredBranch !== null) {
1526
+ try {
1527
+ const remote = await git(deps, ["ls-remote", "--heads", "origin", discoveredBranch]);
1528
+ if (remote.exitCode !== 0) {
1529
+ probeErrors.push({ probe: "branch_head", reason: "git ls-remote failed" });
1530
+ }
1531
+ else {
1532
+ branchHead = lsRemoteSha(remote);
1533
+ }
1534
+ }
1535
+ catch {
1536
+ probeErrors.push({ probe: "branch_head", reason: "git ls-remote could not be run" });
1537
+ }
1538
+ if (branchHead !== null) {
1539
+ try {
1540
+ await git(deps, ["fetch", "origin"]);
1541
+ const counted = await git(deps, [
1542
+ "rev-list",
1543
+ "--count",
1544
+ `origin/${checkpoint.epic_branch}..origin/${discoveredBranch}`,
1545
+ ]);
1546
+ if (counted.exitCode === 0) {
1547
+ const parsedCount = Number.parseInt(firstLine(counted) ?? "", 10);
1548
+ workerCommits = Number.isSafeInteger(parsedCount) && parsedCount >= 0 ? parsedCount : 0;
1549
+ }
1550
+ else {
1551
+ probeErrors.push({ probe: "worker_commits", reason: "git rev-list failed" });
1552
+ }
1553
+ }
1554
+ catch {
1555
+ probeErrors.push({ probe: "worker_commits", reason: "git rev-list could not be run" });
1556
+ }
1557
+ }
1558
+ }
1559
+ // --- PR state -------------------------------------------------------------
1560
+ let pr = null;
1561
+ if (discoveredBranch !== null) {
1562
+ const probe = await discoverConductEpicPrState(discoveredBranch, {
1563
+ runGh: deps.runGh,
1564
+ cwd: deps.cwd,
1565
+ });
1566
+ if (probe.kind === "ok")
1567
+ pr = probe.pr;
1568
+ else if (probe.kind === "error")
1569
+ probeErrors.push({ probe: "pr", reason: probe.reason });
1570
+ }
1571
+ const mergedExternally = pr?.state === "MERGED" && ticket !== null && ticket.status !== "merged" && ticket.status !== "done";
1572
+ // --- Bridge-backed facts --------------------------------------------------
1573
+ const access = accessProbe.ok ? accessProbe.access : null;
1574
+ if (!accessProbe.ok) {
1575
+ probeErrors.push({ probe: "credentials", reason: accessProbe.error });
1576
+ }
1577
+ let doneGateRequired = null;
1578
+ let reviewOptedIn = false;
1579
+ let reviewSource = null;
1580
+ let reviewDisposition = null;
1581
+ // BAPI-915. An UNREADABLE review policy is not an absent one.
1582
+ //
1583
+ // Before this, an unparseable `done_gate_config` yielded `conditions: []`,
1584
+ // which set `reviewOptedIn = false` — so the pilot read a malformed review
1585
+ // policy as "no review opt-in" and merged on CI alone. That is precisely the
1586
+ // accidental fail-open the Python `INVALID` sentinel exists to prevent, and it
1587
+ // cannot coexist with this ticket's invariant that no malformed input can
1588
+ // select `fail_open`.
1589
+ //
1590
+ // The parser's own `reason` draws the line: `unset` and `disabled` are a
1591
+ // genuine operator opt-out and stay opted out; `malformed` and every
1592
+ // `invalid: …` reason are a policy we could not read, and surface as
1593
+ // opted-in / unavailable / config-invalid so the command's Row 10 handles them
1594
+ // and — having no readable condition — parks under the default disposition.
1595
+ let reviewConfigInvalid = false;
1596
+ if (access !== null) {
1597
+ const setup = await getEffectiveSupervisorSetup(access, deps.fetchImpl);
1598
+ if (!setup.ok) {
1599
+ probeErrors.push({ probe: "supervisor_setup", reason: setup.error });
1600
+ }
1601
+ else {
1602
+ const gate = parseDoneGateConfig(setup.value.done_gate_config);
1603
+ if (gate.reason === "malformed" || gate.reason.startsWith("invalid:")) {
1604
+ reviewConfigInvalid = true;
1605
+ reviewOptedIn = true;
1606
+ // No readable condition exists, so no disposition may be read from one.
1607
+ // `null` leaves the command's effective default at `park`.
1608
+ reviewSource = null;
1609
+ reviewDisposition = null;
1610
+ }
1611
+ for (const condition of gate.conditions) {
1612
+ if (condition.type === "required_ci_checks_green") {
1613
+ doneGateRequired = [...condition.required_checks];
1614
+ }
1615
+ else if (condition.type === "review_state") {
1616
+ reviewOptedIn = true;
1617
+ reviewSource = condition.source;
1618
+ reviewDisposition = condition.verdictless_disposition ?? null;
1619
+ }
1620
+ }
1621
+ }
1622
+ }
1623
+ const ci = access === null || pr?.head_sha == null
1624
+ ? null
1625
+ : await collectCiFacts(deps, access, pr.head_sha, doneGateRequired, checkpoint.ci_last_poll, probeErrors);
1626
+ // BAPI-915: the three additional fields are appended to every shape this
1627
+ // sub-object takes, so a consumer never has to branch on whether they exist.
1628
+ const reviewPolicyFacts = {
1629
+ verdictless_disposition: reviewDisposition,
1630
+ verdictless_ceiling: CONDUCT_EPIC_REVIEW_VERDICTLESS_CEILING,
1631
+ config_invalid: reviewConfigInvalid,
1632
+ };
1633
+ let review = {
1634
+ opted_in: reviewOptedIn,
1635
+ source: reviewSource,
1636
+ available: null,
1637
+ verdict: null,
1638
+ head_sha: null,
1639
+ ...reviewPolicyFacts,
1640
+ };
1641
+ // A config we could not read is reported as opted-in and UNAVAILABLE without
1642
+ // any read being attempted: there is no condition to evaluate a review
1643
+ // against, so `available: false` is the honest answer rather than `null`.
1644
+ if (reviewConfigInvalid) {
1645
+ review = {
1646
+ opted_in: true,
1647
+ source: null,
1648
+ available: false,
1649
+ verdict: null,
1650
+ head_sha: null,
1651
+ ...reviewPolicyFacts,
1652
+ };
1653
+ }
1654
+ else if (access !== null && reviewOptedIn && pr?.number != null) {
1655
+ const status = await getPrReviewStatus(access, pr.number, deps.fetchImpl);
1656
+ if (!status.ok) {
1657
+ probeErrors.push({ probe: "review", reason: status.error });
1658
+ review = {
1659
+ opted_in: true,
1660
+ source: reviewSource,
1661
+ available: null,
1662
+ verdict: null,
1663
+ head_sha: null,
1664
+ ...reviewPolicyFacts,
1665
+ };
1666
+ }
1667
+ else {
1668
+ review = {
1669
+ ...normalizeReviewStatus(status.value, reviewOptedIn, reviewSource),
1670
+ ...reviewPolicyFacts,
1671
+ };
1672
+ }
1673
+ }
1674
+ let parse = null;
1675
+ if (access !== null) {
1676
+ const parseStatus = await getParseStatus(access, deps.fetchImpl);
1677
+ if (!parseStatus.ok) {
1678
+ probeErrors.push({ probe: "parse", reason: parseStatus.error });
1679
+ }
1680
+ else {
1681
+ parse = normalizeParseStatus(parseStatus.value);
1682
+ }
1683
+ }
1684
+ // --- scope (BAPI-845) ----------------------------------------------------
1685
+ // The pilot's freshness evidence, asked DIRECTLY. `parse` above is
1686
+ // repository-level and says nothing about whether *this* merge was indexed,
1687
+ // which is why Row 5 used to reconstruct causality from a request timestamp and
1688
+ // a head SHA. This probe replaces that inference with the scope's own answer:
1689
+ // its lifecycle, both watermarks, and a bounded freshness verdict.
1690
+ //
1691
+ // Absent only when the epic declares no scope — a pilot epic cut before
1692
+ // BAPI-843, or a non-scope run. That is a calm `null` with no probe error,
1693
+ // because there is nothing to report rather than something we failed to read.
1694
+ let scope = null;
1695
+ const declaredScopeId = typeof checkpoint.index_scope_id === "string" && checkpoint.index_scope_id.length > 0
1696
+ ? checkpoint.index_scope_id
1697
+ : null;
1698
+ if (access !== null && declaredScopeId !== null) {
1699
+ const scopeStatus = await getIndexScopeStatus(access, declaredScopeId, deps.fetchImpl);
1700
+ if (!scopeStatus.ok) {
1701
+ probeErrors.push({ probe: "scope", reason: scopeStatus.error });
1702
+ // An unread scope is reported as explicitly `unavailable` rather than left
1703
+ // null: a null could be read as "no scope declared", and that reading would
1704
+ // let the loop proceed past a freshness question it never got an answer to.
1705
+ scope = {
1706
+ scope_id: declaredScopeId,
1707
+ lifecycle_state: null,
1708
+ freshness_status: "unavailable",
1709
+ blocked_reason: null,
1710
+ required_commit_sha: null,
1711
+ indexed_commit_sha: null,
1712
+ last_error: null,
1713
+ };
1714
+ }
1715
+ else {
1716
+ scope = {
1717
+ scope_id: scopeStatus.value.scope_id,
1718
+ lifecycle_state: scopeStatus.value.lifecycle_state,
1719
+ // Fail closed: the client already narrowed an unrecognized value to null,
1720
+ // and null here means "not fresh", never "fine".
1721
+ freshness_status: scopeStatus.value.freshness_status ?? "unavailable",
1722
+ blocked_reason: scopeStatus.value.blocked_reason,
1723
+ required_commit_sha: scopeStatus.value.required_commit_sha,
1724
+ indexed_commit_sha: scopeStatus.value.indexed_commit_sha,
1725
+ last_error: scopeStatus.value.last_error,
1726
+ };
1727
+ }
1728
+ }
1729
+ // --- BAPI-963: what a `pending` scope is actually waiting on ----------------
1730
+ //
1731
+ // Read ONLY when the scope is `pending`, because that is the single state where
1732
+ // "who is going to move this?" is the operator's question. A fresh, blocked, or
1733
+ // failed scope is not waiting on a dispatcher, and issuing the request anyway
1734
+ // would add a round trip to every ordinary tick.
1735
+ //
1736
+ // Fail-open like every other status probe: a failed read records a bounded
1737
+ // probe error and reports `unavailable`, which renders the pre-existing
1738
+ // headline unchanged. Absence is claimed only on positive evidence.
1739
+ let dispatcher = null;
1740
+ if (access !== null && scope !== null && scope.freshness_status === "pending") {
1741
+ const health = await getParseDispatcherHealth(access, deps.fetchImpl);
1742
+ if (health.ok) {
1743
+ dispatcher = health.value;
1744
+ }
1745
+ else {
1746
+ probeErrors.push({ probe: "parse_dispatcher", reason: health.error });
1747
+ dispatcher = {
1748
+ observation: "unavailable",
1749
+ heartbeatState: null,
1750
+ respondingSchedulerRunning: false,
1751
+ };
1752
+ }
1753
+ }
1754
+ // --- BAPI-846: the repository's index scopes, and this epic's heartbeat ---
1755
+ //
1756
+ // TWO distinct jobs, both belonging here rather than in a daemon:
1757
+ //
1758
+ // 1. The LISTING makes a crashed epic's scope discoverable. `scope` above
1759
+ // answers "is MY index fresh?"; this answers "what index scopes exist, and
1760
+ // which of them is stranded?" — including expired, reclaiming, and reclaimed
1761
+ // ones, which is what makes a stale scope actionable without SQL.
1762
+ // 2. The HEARTBEAT renews this epic's lease. The pilot has no long-lived local
1763
+ // process — `/loop 5m /conduct-epic` re-invokes this command on a timer, so
1764
+ // the tick loop IS the heartbeat owner, and its cadence matches the default
1765
+ // heartbeat interval. Beating from here means ownership is renewed exactly
1766
+ // while a conductor is actively driving the epic, and stops the moment it
1767
+ // stops ticking, with no untracked daemon and no shutdown contract to get
1768
+ // wrong.
1769
+ //
1770
+ // Both are probes: a failure lands in `probe_errors` and never fails `status`.
1771
+ let scopes = [];
1772
+ let retentionSeconds = null;
1773
+ let nextLeaseEpoch = checkpoint.index_scope_lease_epoch;
1774
+ if (access !== null) {
1775
+ const listing = await getIndexScopeLifecycle(access, deps.fetchImpl);
1776
+ if (!listing.ok) {
1777
+ probeErrors.push({ probe: "scopes", reason: listing.error });
1778
+ }
1779
+ else {
1780
+ scopes = listing.value.scopes;
1781
+ retentionSeconds = listing.value.retention_seconds;
1782
+ }
1783
+ if (declaredScopeId !== null) {
1784
+ // The epoch comes from the SERVER's listing when it is readable, and from
1785
+ // the checkpoint only as a fallback. Preferring the server is what lets a
1786
+ // conductor keep beating after an operator `recover` superseded its
1787
+ // generation — the alternative is a healthy conductor permanently fenced by
1788
+ // a stale local number.
1789
+ const entry = scopes.find((scope) => scope.scope_id === declaredScopeId);
1790
+ const epoch = entry?.lease_epoch ?? checkpoint.index_scope_lease_epoch;
1791
+ if (epoch !== null && entry?.recoverable !== false) {
1792
+ const beat = await heartbeatIndexScope(access, { scopeId: declaredScopeId, leaseEpoch: epoch }, deps.fetchImpl);
1793
+ if (beat.ok) {
1794
+ nextLeaseEpoch = beat.value.lease_epoch;
1795
+ }
1796
+ else {
1797
+ // A rejected beat means fenced or retired. Recorded as a probe error so
1798
+ // the loop SEES it, not swallowed — but never fatal, because `status`
1799
+ // is also how an operator finds out they were fenced.
1800
+ probeErrors.push({ probe: "scope_heartbeat", reason: beat.error });
1801
+ }
1802
+ }
1803
+ }
1804
+ }
1805
+ // --- lock (inspected, NEVER acquired) ------------------------------------
1806
+ const lockState = await inspectConductEpicLock(resolveConductEpicLockPath(checkpointPath), lockRequest(deps), buildConductEpicLockSeams(deps));
1807
+ const lock = {
1808
+ held_by_me: lockState.kind === "owned",
1809
+ owner_pid: "owner" in lockState ? lockState.owner.owner_pid : null,
1810
+ host: "owner" in lockState ? lockState.owner.host : null,
1811
+ alive: lockState.kind === "owned" || lockState.kind === "live-foreign"
1812
+ ? true
1813
+ : lockState.kind === "dead-local"
1814
+ ? false
1815
+ : null,
1816
+ };
1817
+ // --- the permitted writes ------------------------------------------------
1818
+ // BAPI-846 added the scope's fencing epoch, refreshed from the server's
1819
+ // authoritative answer; BAPI-963 added the observed `ticket.pr_number`. Every
1820
+ // one of them rides in the SAME atomic write rather than in a second one, so a
1821
+ // tick either records everything it observed or nothing.
1822
+ let lastSeenHead = ticket?.last_seen_head ?? null;
1823
+ let lastStateChangeAt = ticket?.last_state_change_at ?? null;
1824
+ // BAPI-963: what the response PROJECTS for the in-flight ticket. It advances to
1825
+ // the mutated clone only after the atomic write proves durable, so the payload
1826
+ // cannot report a self-healed `pr_number` that never reached disk.
1827
+ let projectedTicket = ticket;
1828
+ const leaseEpochChanged = nextLeaseEpoch !== checkpoint.index_scope_lease_epoch;
1829
+ if (ticket !== null) {
1830
+ const next = { ...checkpoint, tickets: [...checkpoint.tickets] };
1831
+ const index = next.tickets.findIndex((entry) => entry.key === ticket.key);
1832
+ let dirty = false;
1833
+ const updatedTicket = { ...next.tickets[index] };
1834
+ if (updatedTicket.branch === null && discoveredBranch !== null) {
1835
+ updatedTicket.branch = discoveredBranch;
1836
+ dirty = true;
1837
+ }
1838
+ if (branchHead !== null && branchHead !== updatedTicket.last_seen_head) {
1839
+ updatedTicket.last_state_change_at = now.toISOString();
1840
+ updatedTicket.last_seen_head = branchHead;
1841
+ lastSeenHead = branchHead;
1842
+ lastStateChangeAt = updatedTicket.last_state_change_at;
1843
+ dirty = true;
1844
+ }
1845
+ // BAPI-963: self-heal the PR number from the PR this tick already observed.
1846
+ // `pr.number` is the validated positive-integer-or-null the PR-state parser
1847
+ // produced, so nothing unvalidated reaches the checkpoint. No PR observed
1848
+ // means no write at all — a null observation must never erase a number a
1849
+ // previous tick durably recorded.
1850
+ if (pr !== null && pr.number !== null && pr.number !== updatedTicket.pr_number) {
1851
+ updatedTicket.pr_number = pr.number;
1852
+ dirty = true;
1853
+ }
1854
+ next.tickets[index] = updatedTicket;
1855
+ if (ci?.ci_last_poll) {
1856
+ next.ci_last_poll = ci.ci_last_poll;
1857
+ dirty = true;
1858
+ }
1859
+ if (leaseEpochChanged) {
1860
+ next.index_scope_lease_epoch = nextLeaseEpoch;
1861
+ dirty = true;
1862
+ }
1863
+ if (dirty) {
1864
+ next.updated_at = now.toISOString();
1865
+ const written = await writeConductEpicCheckpointAtomic(checkpointPath, next, deps.fs, {
1866
+ skipChmod: deps.platform === "win32",
1867
+ });
1868
+ if (!written.ok) {
1869
+ probeErrors.push({ probe: "checkpoint_write", reason: written.error });
1870
+ }
1871
+ else {
1872
+ // Project what was actually PERSISTED. A failed write leaves the response
1873
+ // reporting the original stored value, so the payload never claims a
1874
+ // durable record that is not on disk.
1875
+ projectedTicket = updatedTicket;
1876
+ }
1877
+ }
1878
+ }
1879
+ const payload = {
1880
+ ok: true,
1881
+ epic_key: checkpoint.epic_key,
1882
+ epic_branch: checkpoint.epic_branch,
1883
+ checkpoint_path: checkpointPath,
1884
+ checkpoint_exists: true,
1885
+ all_done: allDone,
1886
+ ticket: projectedTicket === null
1887
+ ? null
1888
+ : projectConductEpicTicketFacts(projectedTicket, discoveredBranch),
1889
+ worktree_path: worktreePath,
1890
+ worktree_exists: worktreeExists,
1891
+ branch_head: branchHead,
1892
+ worker_commits_since_spawn: workerCommits,
1893
+ last_seen_head: lastSeenHead,
1894
+ last_state_change_at: lastStateChangeAt,
1895
+ stale_for_seconds: elapsedSeconds(lastStateChangeAt, now),
1896
+ pr,
1897
+ merged_externally: Boolean(mergedExternally),
1898
+ ci: ci?.ci ?? null,
1899
+ review,
1900
+ parse,
1901
+ deadlines: {
1902
+ soft_seconds: checkpoint.deadlines.soft_seconds,
1903
+ hard_seconds: checkpoint.deadlines.hard_seconds,
1904
+ elapsed_since_spawn_seconds: elapsedSeconds(ticket?.spawned_at ?? null, now),
1905
+ },
1906
+ scope,
1907
+ // BAPI-963: `observed` | `absent` | `unavailable`, or null when the question
1908
+ // did not arise (no scope, no access, or a scope that is not `pending`).
1909
+ parse_dispatcher: dispatcher === null ? null : dispatcher.observation,
1910
+ scope_lease_epoch: nextLeaseEpoch,
1911
+ retention_seconds: retentionSeconds,
1912
+ scopes,
1913
+ lock,
1914
+ needs_human: checkpoint.needs_human,
1915
+ probe_errors: probeErrors,
1916
+ };
1917
+ return emitSuccess(deps, options.json, payload, [
1918
+ ...renderScopeFreshnessLines(scope, dispatcher),
1919
+ ...renderStrandedScopeLines(scopes, declaredScopeId),
1920
+ ]);
1921
+ }
1922
+ /**
1923
+ * Render the compact freshness unit for the human-readable `status` output.
1924
+ *
1925
+ * There is no dashboard for an index scope and this deliberately does not invent
1926
+ * one — the unit lives on the status surface that already exists. Its shape is
1927
+ * fixed by what an operator needs to decide, in that order:
1928
+ *
1929
+ * 1. **What is happening to the ticket**, first and in plain language. "Waiting
1930
+ * for index refresh" is the answer to the question actually being asked; a
1931
+ * lifecycle name is not.
1932
+ * 2. **The lifecycle**, then the two commits on SEPARATE, SEPARATELY LABELLED
1933
+ * lines. Printing them together, or printing only one, is what let "the commit
1934
+ * we must index" read as "the commit we indexed".
1935
+ * 3. **The refusal, spelled out**, when there is one. A controlled token is
1936
+ * precise but not self-explaining, so each is given a sentence — and each
1937
+ * sentence distinguishes it from a plain parse failure.
1938
+ *
1939
+ * Returns an empty array when the epic declares no scope, so an epic without one
1940
+ * prints exactly what it printed before.
1941
+ */
1942
+ /**
1943
+ * Render the STRANDED-SCOPE warning unit for `status` (BAPI-846).
1944
+ *
1945
+ * Deliberately narrow: it names only scopes that are not this epic's and are not
1946
+ * live — the crashed-epic case an operator can act on — and it says nothing at
1947
+ * all when there are none. A full inventory belongs in the JSON payload; the
1948
+ * human output exists to make one specific problem impossible to miss, and a
1949
+ * block that prints on every healthy tick is a block operators stop reading.
1950
+ *
1951
+ * `reclaimed` scopes are omitted: a tombstone is a completed outcome, not
1952
+ * something to act on.
1953
+ */
1954
+ function renderStrandedScopeLines(scopes, ownScopeId) {
1955
+ const stranded = scopes.filter((scope) => scope.scope_id !== ownScopeId &&
1956
+ scope.lifecycle_state !== "reclaimed" &&
1957
+ !scope.lease_valid);
1958
+ if (stranded.length === 0)
1959
+ return [];
1960
+ const lines = [
1961
+ `${stranded.length} index scope(s) in this repository have no live lease:`,
1962
+ ];
1963
+ for (const scope of stranded) {
1964
+ const action = scope.recoverable
1965
+ ? "recoverable — `implement-epic recover <EPIC> --scope " + scope.scope_id + "`"
1966
+ : scope.retention_elapsed
1967
+ ? "past retention — the sweep will reclaim it"
1968
+ : `retained until ${scope.retention_deadline ?? "an unknown deadline"}`;
1969
+ lines.push(` ${scope.scope_id} ${scope.lifecycle_state ?? "unknown"} ` +
1970
+ `branch=${scope.feature_branch ?? "unknown"} ${action}`);
1971
+ if (scope.blockers.length > 0) {
1972
+ lines.push(` blocked by: ${scope.blockers.join(", ")}`);
1973
+ }
1974
+ }
1975
+ return lines;
1976
+ }
1977
+ function renderScopeFreshnessLines(scope, dispatcher = null) {
1978
+ if (scope === null)
1979
+ return [];
1980
+ // BAPI-963: `pending` gets a sharper headline when nothing is observed sweeping
1981
+ // the parse queue — "Waiting for index refresh" implies autonomous progress
1982
+ // that, with no dispatcher, is never going to happen. Only a POSITIVE `absent`
1983
+ // observation changes the wording; `observed` and `unavailable` both keep the
1984
+ // original line, because an unavailable read is not evidence of absence.
1985
+ const dispatcherAbsent = dispatcher !== null && dispatcher.observation === "absent";
1986
+ const headline = {
1987
+ fresh: "Index is fresh for this epic.",
1988
+ pending: dispatcherAbsent
1989
+ ? "Waiting for live parse dispatcher."
1990
+ : "Waiting for index refresh.",
1991
+ blocked: "Index refresh is BLOCKED — this advance will not be indexed.",
1992
+ failed: "Index generation FAILED for this scope.",
1993
+ unavailable: "Index freshness is unavailable — treat as not fresh.",
1994
+ unobserved_advance: "The epic branch has advanced BEYOND what this index scope observed.",
1995
+ };
1996
+ // The action line sits immediately under the headline so the output reads
1997
+ // status -> action -> evidence. `unobserved_advance` is the one state that is
1998
+ // actionable rather than merely informative: it is repairable in place.
1999
+ const action = {
2000
+ unobserved_advance: " Attempting guarded catch-up — replaying the missed merges through the same gate.",
2001
+ };
2002
+ const refusal = {
2003
+ advance_blocked_base_merge: "the base branch was merged forward into the epic branch, which would move the branch's pinned cut point",
2004
+ advance_blocked_unexpected_parent: "the merge commit does not descend directly from the head this scope pinned, so it is not a worker merge",
2005
+ advance_blocked_history_changed: "the pinned head is gone from the branch's history — a force-push or rewrite",
2006
+ advance_blocked_unverifiable: "the advance could not be verified at all, and doubt blocks rather than indexes",
2007
+ };
2008
+ const lines = [
2009
+ headline[scope.freshness_status] ?? "Index freshness is unknown — treat as not fresh.",
2010
+ ];
2011
+ const actionLine = action[scope.freshness_status];
2012
+ if (actionLine !== undefined)
2013
+ lines.push(actionLine);
2014
+ if (scope.freshness_status === "pending" && dispatcherAbsent) {
2015
+ lines.push(" No parse dispatcher was observed. The scheduled refresh cannot begin until", " the `worker:` dyno is sweeping the parse queue.");
2016
+ }
2017
+ lines.push(` lifecycle: ${scope.lifecycle_state ?? "unknown"}`, ` Required commit: ${scope.required_commit_sha ?? "none"}`, ` Indexed commit: ${scope.indexed_commit_sha ?? "none"}`);
2018
+ if (scope.freshness_status === "unobserved_advance") {
2019
+ lines.push(" Both watermarks agree — at a commit the branch has left behind, which is", " why this is not `fresh`. Run `implement-epic catch-up <EPIC>` to repair it.");
2020
+ }
2021
+ if (scope.blocked_reason !== null) {
2022
+ lines.push(` Reason: ${scope.blocked_reason} — ${refusal[scope.blocked_reason] ?? "the server refused this branch advance"}`);
2023
+ lines.push(" A human must resolve the branch before the epic can continue.");
2024
+ }
2025
+ else if (scope.freshness_status === "failed" && scope.last_error !== null) {
2026
+ lines.push(` Failure category: ${scope.last_error}`);
2027
+ }
2028
+ return lines;
2029
+ }
2030
+ /**
2031
+ * Project one checkpoint ticket into the published `ticket` facts.
2032
+ *
2033
+ * `branch` comes from the caller's freshly discovered value rather than the
2034
+ * stored one, matching the rest of the status contract. `journal` is copied so
2035
+ * a consumer of the payload can never reach back into checkpoint state — the
2036
+ * status verb's write allowlist is deliberately narrow, and handing out a live
2037
+ * array reference would be a fifth, undeclared write path.
2038
+ */
2039
+ export function projectConductEpicTicketFacts(ticket, discoveredBranch) {
2040
+ return {
2041
+ key: ticket.key,
2042
+ status: ticket.status,
2043
+ branch: discoveredBranch,
2044
+ pr_number: ticket.pr_number,
2045
+ spawned_at: ticket.spawned_at,
2046
+ parse_requested_at: ticket.parse_requested_at,
2047
+ parse_requested_for_sha: ticket.parse_requested_for_sha,
2048
+ review_verdictless_observations: ticket.review_verdictless_observations,
2049
+ review_verdictless_for_sha: ticket.review_verdictless_for_sha,
2050
+ respawns: ticket.respawns,
2051
+ conflict_attempts: ticket.conflict_attempts,
2052
+ counters: { ...ticket.counters },
2053
+ journal: [...ticket.journal],
2054
+ };
2055
+ }
2056
+ /**
2057
+ * Poll CI for `headSha`, warming the resolve cache at most once.
2058
+ *
2059
+ * `poll-ci-checks` answers `available: false` with a "call resolve-ci-checks
2060
+ * first" action until the repository's check config has been cached. That is an
2061
+ * INSTRUCTION, not an error: resolve once, poll once more, and proceed. Resolve
2062
+ * is also what supplies the required names when the done gate does not.
2063
+ */
2064
+ async function collectCiFacts(deps, access, headSha, doneGateRequired, previous, probeErrors) {
2065
+ let resolvedValue = null;
2066
+ let resolveCalled = false;
2067
+ const ensureResolved = async () => {
2068
+ if (resolveCalled)
2069
+ return;
2070
+ resolveCalled = true;
2071
+ const resolved = await resolveCiChecks(access, headSha, deps.fetchImpl);
2072
+ if (resolved.ok)
2073
+ resolvedValue = resolved.value;
2074
+ else
2075
+ probeErrors.push({ probe: "ci_resolve", reason: resolved.error });
2076
+ };
2077
+ let polled = await pollCiChecks(access, headSha, deps.fetchImpl);
2078
+ if (!polled.ok) {
2079
+ probeErrors.push({ probe: "ci", reason: polled.error });
2080
+ return null;
2081
+ }
2082
+ if (isRecord(polled.value) && polled.value["available"] === false) {
2083
+ await ensureResolved();
2084
+ polled = await pollCiChecks(access, headSha, deps.fetchImpl);
2085
+ if (!polled.ok) {
2086
+ probeErrors.push({ probe: "ci", reason: polled.error });
2087
+ return null;
2088
+ }
2089
+ // Still unavailable after the one resolve-and-re-poll: CI state is UNKNOWN
2090
+ // (BAPI-814/B3). Falling through would build `{complete: false, checks: []}`,
2091
+ // which is indistinguishable from "polled fine, nothing is green yet" — and
2092
+ // would persist a fingerprint derived from an answer that carried no checks,
2093
+ // so the NEXT poll would compare against it and could report
2094
+ // `stable_across_two_polls`. Return before either happens.
2095
+ if (isRecord(polled.value) && polled.value["available"] === false) {
2096
+ probeErrors.push({
2097
+ probe: "ci",
2098
+ reason: "CI checks are unavailable after resolve-ci-checks and a second poll",
2099
+ });
2100
+ return null;
2101
+ }
2102
+ }
2103
+ const detail = isRecord(polled.value) ? polled.value["detail"] : null;
2104
+ const rawChecks = isRecord(detail) && Array.isArray(detail["checks"]) ? detail["checks"] : [];
2105
+ const checks = rawChecks.filter(isRecord).map((check) => ({
2106
+ name: typeof check["name"] === "string" ? check["name"] : "",
2107
+ status: check["status"] ?? null,
2108
+ conclusion: check["conclusion"] ?? null,
2109
+ required: check["required"] !== false,
2110
+ }));
2111
+ let required = doneGateRequired;
2112
+ if (required === null) {
2113
+ await ensureResolved();
2114
+ required = requiredCheckNamesFromResolve(resolvedValue);
2115
+ }
2116
+ // Sorted so the fingerprint is order-independent: GitHub returns checks in an
2117
+ // arbitrary order, and an order flip must not read as an unstable poll.
2118
+ const requiredSorted = [...new Set(required)].sort();
2119
+ const complete = requiredSorted.length > 0 &&
2120
+ requiredSorted.every((name) => {
2121
+ const raw = rawChecks.filter(isRecord).find((check) => check["name"] === name);
2122
+ return raw !== undefined && raw["complete"] === true && raw["passed"] === true;
2123
+ });
2124
+ const fingerprint = JSON.stringify(checks
2125
+ .filter((check) => requiredSorted.includes(check.name))
2126
+ .map((check) => [check.name, check.status ?? null, check.conclusion ?? null])
2127
+ .sort((a, b) => String(a[0]).localeCompare(String(b[0]))));
2128
+ // Stability is a THREE-part identity: same head, same required set, same
2129
+ // per-check conclusions. A head change resets it, which is the point — a green
2130
+ // result observed twice for an old SHA says nothing about the current one.
2131
+ const stable = previous !== null &&
2132
+ previous.head_sha === headSha &&
2133
+ previous.results_fingerprint === fingerprint &&
2134
+ previous.required.length === requiredSorted.length &&
2135
+ previous.required.every((name, i) => name === requiredSorted[i]);
2136
+ return {
2137
+ ci: {
2138
+ required: requiredSorted,
2139
+ complete,
2140
+ stable_across_two_polls: stable,
2141
+ head_sha: headSha,
2142
+ checks,
2143
+ },
2144
+ ci_last_poll: {
2145
+ head_sha: headSha,
2146
+ required: requiredSorted,
2147
+ results_fingerprint: fingerprint,
2148
+ at: deps.now().toISOString(),
2149
+ },
2150
+ };
2151
+ }
2152
+ /** Normalize the review-status envelope into the contract's five fields. */
2153
+ function normalizeReviewStatus(value, optedIn, source) {
2154
+ const available = isRecord(value) && value["available"] === true;
2155
+ const detail = isRecord(value) && isRecord(value["detail"]) ? value["detail"] : null;
2156
+ let verdict = null;
2157
+ if (detail !== null) {
2158
+ const sticky = detail["sticky_verdict"];
2159
+ const native = detail["review_decision"];
2160
+ if (typeof sticky === "string" && sticky.trim().length > 0) {
2161
+ verdict = normalizeVerdict(sticky);
2162
+ }
2163
+ else if (typeof native === "string" && native.trim().length > 0) {
2164
+ verdict = normalizeVerdict(native);
2165
+ }
2166
+ else {
2167
+ verdict = "unknown";
2168
+ }
2169
+ }
2170
+ return {
2171
+ opted_in: optedIn,
2172
+ source,
2173
+ available: isRecord(value) ? Boolean(value["available"]) : null,
2174
+ // An `available: false` envelope still carries whatever verdict the server
2175
+ // could derive; it is passed through rather than nulled, because the caller
2176
+ // reads `available` to decide how much to trust it.
2177
+ verdict: available || verdict !== null ? verdict : null,
2178
+ head_sha: detail !== null && typeof detail["head_sha"] === "string" ? detail["head_sha"] : null,
2179
+ };
2180
+ }
2181
+ /** Map every known verdict spelling onto the contract's three values. */
2182
+ function normalizeVerdict(raw) {
2183
+ const value = raw.trim().toLowerCase();
2184
+ if (value === "approved")
2185
+ return "approved";
2186
+ if (value === "changes_requested")
2187
+ return "changes_requested";
2188
+ return "unknown";
2189
+ }
2190
+ /**
2191
+ * Normalize `/jira/parse-status` into the `parse` sub-object.
2192
+ *
2193
+ * The two timestamps are what make the post-merge barrier CAUSAL rather than
2194
+ * coincidental (BAPI-814/A2). `parse.status` is repository-level and stays
2195
+ * `succeeded` from any earlier parse, so "succeeded" alone cannot tell the loop
2196
+ * whether THIS ticket's merge has been indexed; a start or finish strictly later
2197
+ * than the journaled request time can.
2198
+ *
2199
+ * Malformed primitives become `null` rather than being coerced. A blank string
2200
+ * or a number where a timestamp belongs is unavailable evidence, and coercing it
2201
+ * to something truthy would let the barrier pass on a value that means nothing.
2202
+ */
2203
+ export function normalizeParseStatus(value) {
2204
+ const known = ["idle", "queued", "in_progress", "succeeded", "failed"];
2205
+ const raw = isRecord(value) ? value["status"] : null;
2206
+ const status = typeof raw === "string" && known.includes(raw) ? raw : null;
2207
+ const optionalText = (key) => {
2208
+ const field = isRecord(value) ? value[key] : null;
2209
+ if (typeof field !== "string")
2210
+ return null;
2211
+ const trimmed = field.trim();
2212
+ return trimmed.length === 0 ? null : trimmed;
2213
+ };
2214
+ return {
2215
+ status,
2216
+ terminal: status === "succeeded" || status === "failed",
2217
+ started_at: optionalText("started_at"),
2218
+ finished_at: optionalText("finished_at"),
2219
+ };
2220
+ }
2221
+ // ---------------------------------------------------------------------------
2222
+ // checkpoint set
2223
+ // ---------------------------------------------------------------------------
2224
+ /**
2225
+ * `implement-epic checkpoint set` — schema-validated ABSOLUTE edits under the lock.
2226
+ *
2227
+ * Absolute, not relative: the caller reads `status`, computes `n + 1`, and writes
2228
+ * the result. A `--increment` flag would need the CLI to re-read between the
2229
+ * caller's read and its write, which is exactly the lost-update race the lock
2230
+ * exists to prevent.
2231
+ */
2232
+ export async function runConductEpicCheckpointSet(deps, options) {
2233
+ // No credential probe here: `checkpoint set` is entirely local — a file read,
2234
+ // a schema-validated edit, and an atomic write. Resolving Bridge access would
2235
+ // make a purely local operation fail-prone during a credential outage.
2236
+ const repoName = await resolveRepoNameForPath(deps);
2237
+ const checkpointPath = resolveCheckpointPath(deps, repoName, options.epicKey, options.checkpointPath);
2238
+ // LOCK FIRST, then read (BAPI-814/B5). Reading before acquiring is a
2239
+ // read-before-lock race: the bytes this call validates and rewrites could be
2240
+ // replaced by another process between the read and the acquisition, and the
2241
+ // write would then silently discard that process's update. Everything that
2242
+ // observes or mutates checkpoint state now happens inside one lock lifetime.
2243
+ const lock = await acquireConductEpicLock(resolveConductEpicLockPath(checkpointPath), lockRequest(deps), buildConductEpicLockSeams(deps));
2244
+ if (!lock.acquired) {
2245
+ return emitFailure(deps, options.json, [`The epic lock could not be acquired: ${lock.reason}`]);
2246
+ }
2247
+ try {
2248
+ const read = await readConductEpicCheckpoint(checkpointPath, deps.fs);
2249
+ if (read.kind === "missing") {
2250
+ return emitFailure(deps, options.json, [
2251
+ `No checkpoint exists at ${checkpointPath}. Run \`implement-epic init\` first.`,
2252
+ ]);
2253
+ }
2254
+ if (read.kind !== "ok")
2255
+ return emitFailure(deps, options.json, [read.error]);
2256
+ const checkpoint = read.checkpoint;
2257
+ const ticketKey = options.ticket;
2258
+ const index = checkpoint.tickets.findIndex((entry) => entry.key === ticketKey);
2259
+ if (index === -1) {
2260
+ return emitFailure(deps, options.json, [
2261
+ `${ticketKey} is not one of this epic's tickets (${checkpoint.tickets.map((t) => t.key).join(", ")}).`,
2262
+ ]);
2263
+ }
2264
+ const next = {
2265
+ ...checkpoint,
2266
+ counters: { ...checkpoint.counters },
2267
+ tickets: checkpoint.tickets.map((entry) => ({
2268
+ ...entry,
2269
+ counters: { ...entry.counters },
2270
+ journal: [...entry.journal],
2271
+ })),
2272
+ };
2273
+ const now = deps.now().toISOString();
2274
+ const statusBefore = next.tickets[index].status;
2275
+ // Applied in argv order, so a repeated field's LAST assignment wins — the
2276
+ // same rule a shell applies to repeated options.
2277
+ for (const assignment of options.fields) {
2278
+ const applied = applyFieldAssignment(next, index, assignment);
2279
+ if (applied !== null) {
2280
+ return emitFailure(deps, options.json, [applied]);
2281
+ }
2282
+ }
2283
+ if (next.tickets[index].status !== statusBefore) {
2284
+ next.tickets[index].last_state_change_at = now;
2285
+ }
2286
+ if (options.journal !== undefined) {
2287
+ next.tickets[index] = appendTicketJournal(next.tickets[index], options.journal);
2288
+ }
2289
+ next.updated_at = now;
2290
+ const written = await writeConductEpicCheckpointAtomic(checkpointPath, next, deps.fs, {
2291
+ skipChmod: deps.platform === "win32",
2292
+ });
2293
+ if (!written.ok)
2294
+ return emitFailure(deps, options.json, [written.error]);
2295
+ // A concise mutation summary: the FIELD NAMES that changed, never the
2296
+ // checkpoint body, which can carry operator-authored `needs_human` evidence.
2297
+ const changed = options.fields.map((f) => f.name);
2298
+ if (options.journal !== undefined)
2299
+ changed.push("journal");
2300
+ return emitSuccess(deps, options.json, {
2301
+ ok: true,
2302
+ epic_key: checkpoint.epic_key,
2303
+ ticket: ticketKey,
2304
+ updated_fields: changed,
2305
+ checkpoint_path: checkpointPath,
2306
+ }, [`Updated ${ticketKey}: ${changed.join(", ")}`]);
2307
+ }
2308
+ finally {
2309
+ await releaseAcquired(lock);
2310
+ }
2311
+ }
2312
+ /** Release a lock we acquired, never one a successor replaced it with. */
2313
+ async function releaseAcquired(lock) {
2314
+ await lock.release();
2315
+ }
2316
+ /**
2317
+ * Apply one `--field name value` assignment in place, returning an error message
2318
+ * or `null`.
2319
+ *
2320
+ * Every value is parsed by TYPE, not coerced: `"null"` is only null where null is
2321
+ * a legal value, a counter must be a non-negative integer, a branch must survive
2322
+ * `validateBranchName`, and a status must be in the closed vocabulary. A
2323
+ * permissive parser here would let a typo silently write a nonsense state that
2324
+ * the loop then acts on.
2325
+ */
2326
+ function applyFieldAssignment(checkpoint, ticketIndex, assignment) {
2327
+ const { name, value } = assignment;
2328
+ const ticket = checkpoint.tickets[ticketIndex];
2329
+ switch (name) {
2330
+ case "status": {
2331
+ if (!CONDUCT_EPIC_TICKET_STATUSES.includes(value)) {
2332
+ return `Invalid status '${value}'. Expected one of: ${CONDUCT_EPIC_TICKET_STATUSES.join(", ")}`;
2333
+ }
2334
+ ticket.status = value;
2335
+ return null;
2336
+ }
2337
+ case "branch": {
2338
+ if (value === "null") {
2339
+ ticket.branch = null;
2340
+ return null;
2341
+ }
2342
+ const reason = validateBranchName(value);
2343
+ if (reason)
2344
+ return `Invalid branch: ${reason}`;
2345
+ ticket.branch = value;
2346
+ return null;
2347
+ }
2348
+ case "pr_number": {
2349
+ if (value === "null") {
2350
+ ticket.pr_number = null;
2351
+ return null;
2352
+ }
2353
+ const parsed = parseIntegerField(value);
2354
+ if (parsed === null || parsed <= 0)
2355
+ return "pr_number must be a positive integer or null.";
2356
+ ticket.pr_number = parsed;
2357
+ return null;
2358
+ }
2359
+ case "spawned_at": {
2360
+ if (value === "null") {
2361
+ ticket.spawned_at = null;
2362
+ return null;
2363
+ }
2364
+ if (value.trim().length === 0)
2365
+ return "spawned_at must be a non-empty timestamp or null.";
2366
+ ticket.spawned_at = value;
2367
+ return null;
2368
+ }
2369
+ case "parse_requested_at":
2370
+ case "parse_requested_for_sha":
2371
+ // BAPI-915: the head the verdictless counter is bound to. Reuses this
2372
+ // nullable-string branch so a tick can clear the binding as deliberately as
2373
+ // it sets it — the counter resets by writing `null` here, not by hand-editing.
2374
+ case "review_verdictless_for_sha": {
2375
+ // The durable half of the post-merge causal barrier (BAPI-825/A2). Both
2376
+ // follow the module's nullable-string convention so a tick can clear them
2377
+ // as deliberately as it sets them — an operator re-running a parse writes
2378
+ // `null` here rather than hand-editing the checkpoint.
2379
+ if (value === "null") {
2380
+ ticket[name] = null;
2381
+ return null;
2382
+ }
2383
+ if (value.trim().length === 0) {
2384
+ return `${name} must be a non-empty string or null.`;
2385
+ }
2386
+ ticket[name] = value;
2387
+ return null;
2388
+ }
2389
+ case "respawns":
2390
+ case "conflict_attempts":
2391
+ // BAPI-915: the head-bound verdictless observation count. Reuses this
2392
+ // non-negative-integer branch, so a negative or non-integer value is
2393
+ // refused by the same parser that guards every other per-ticket counter.
2394
+ case "review_verdictless_observations": {
2395
+ const parsed = parseIntegerField(value);
2396
+ if (parsed === null)
2397
+ return `${name} must be a non-negative integer.`;
2398
+ ticket[name] = parsed;
2399
+ return null;
2400
+ }
2401
+ case "counters.sessions_spawned":
2402
+ case "counters.plan_generations_observed":
2403
+ case "counters.merge_attempts": {
2404
+ const parsed = parseIntegerField(value);
2405
+ if (parsed === null)
2406
+ return `${name} must be a non-negative integer.`;
2407
+ const key = name.slice("counters.".length);
2408
+ ticket.counters[key] = parsed;
2409
+ return null;
2410
+ }
2411
+ case "counters.iterations":
2412
+ case "counters.merges": {
2413
+ const parsed = parseIntegerField(value);
2414
+ if (parsed === null)
2415
+ return `${name} must be a non-negative integer.`;
2416
+ checkpoint.counters[name.slice("counters.".length)] = parsed;
2417
+ return null;
2418
+ }
2419
+ case "needs_human": {
2420
+ if (value === "null") {
2421
+ checkpoint.needs_human = null;
2422
+ return null;
2423
+ }
2424
+ let parsed;
2425
+ try {
2426
+ parsed = JSON.parse(value);
2427
+ }
2428
+ catch {
2429
+ return "needs_human must be JSON null or an object with reason, evidence, and at.";
2430
+ }
2431
+ if (parsed === null) {
2432
+ checkpoint.needs_human = null;
2433
+ return null;
2434
+ }
2435
+ if (!isRecord(parsed) ||
2436
+ typeof parsed["reason"] !== "string" ||
2437
+ typeof parsed["evidence"] !== "string" ||
2438
+ typeof parsed["at"] !== "string") {
2439
+ return "needs_human must be JSON null or an object with string reason, evidence, and at.";
2440
+ }
2441
+ checkpoint.needs_human = {
2442
+ reason: parsed["reason"],
2443
+ evidence: parsed["evidence"],
2444
+ at: parsed["at"],
2445
+ };
2446
+ return null;
2447
+ }
2448
+ default:
2449
+ return (`Unknown field '${name}'. Ticket fields: ${TICKET_FIELDS.join(", ")}. ` +
2450
+ `Top-level fields: ${TOP_LEVEL_FIELDS.join(", ")}.`);
2451
+ }
2452
+ }
2453
+ /** Parse a non-negative integer, rejecting `"1.5"`, `"1e3"`, `"0x1"`, and `""`. */
2454
+ function parseIntegerField(value) {
2455
+ if (!/^\d+$/.test(value.trim()))
2456
+ return null;
2457
+ const parsed = Number.parseInt(value.trim(), 10);
2458
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
2459
+ }
2460
+ // ---------------------------------------------------------------------------
2461
+ // spawn
2462
+ // ---------------------------------------------------------------------------
2463
+ /**
2464
+ * `implement-epic spawn` — open exactly ONE agent tab for a ticket.
2465
+ *
2466
+ * Respawn and conflict budgets are deliberately NOT enforced here. The caller
2467
+ * owns them: it reads `status`, decides whether another attempt is warranted,
2468
+ * and only then calls `spawn`. Enforcing a budget in both places means two
2469
+ * thresholds that drift, and the one in prose wins by accident.
2470
+ */
2471
+ export async function runConductEpicSpawn(deps, options) {
2472
+ // Local-only, like `checkpoint set`: no credential probe. The spawned agent
2473
+ // resolves its own credentials.
2474
+ const repoName = await resolveRepoNameForPath(deps);
2475
+ const checkpointPath = resolveCheckpointPath(deps, repoName, options.epicKey, options.checkpointPath);
2476
+ // LOCK FIRST (BAPI-814/B5). Checkpoint read, ticket validation, worktree
2477
+ // resolution, prompt read, the spawn itself, and the counter write all live
2478
+ // inside one lock lifetime — so no other process can advance the counters
2479
+ // between the read this call validates and the write it performs.
2480
+ const lock = await acquireConductEpicLock(resolveConductEpicLockPath(checkpointPath), lockRequest(deps), buildConductEpicLockSeams(deps));
2481
+ if (!lock.acquired) {
2482
+ return emitFailure(deps, options.json, [`The epic lock could not be acquired: ${lock.reason}`]);
2483
+ }
2484
+ try {
2485
+ const read = await readConductEpicCheckpoint(checkpointPath, deps.fs);
2486
+ if (read.kind === "missing") {
2487
+ return emitFailure(deps, options.json, [
2488
+ `No checkpoint exists at ${checkpointPath}. Run \`implement-epic init\` first.`,
2489
+ ]);
2490
+ }
2491
+ if (read.kind !== "ok")
2492
+ return emitFailure(deps, options.json, [read.error]);
2493
+ const checkpoint = read.checkpoint;
2494
+ const ticketKey = options.ticket;
2495
+ const index = checkpoint.tickets.findIndex((entry) => entry.key === ticketKey);
2496
+ if (index === -1) {
2497
+ return emitFailure(deps, options.json, [`${ticketKey} is not one of this epic's tickets.`]);
2498
+ }
2499
+ // Resolve the worktree BEFORE reading the prompt and spawning: a tab opened
2500
+ // in the wrong directory does real work in the wrong place.
2501
+ const listed = await git(deps, ["worktree", "list", "--porcelain"]);
2502
+ if (listed.exitCode !== 0) {
2503
+ return emitFailure(deps, options.json, ["git worktree list failed; the ticket worktree could not be resolved."]);
2504
+ }
2505
+ const found = discoverTicketWorktree(parseGitWorktreePorcelain(listed.stdout), ticketKey, checkpoint.tickets[index].branch);
2506
+ if (found === null) {
2507
+ return emitFailure(deps, options.json, [
2508
+ `No worktree was found for ${ticketKey}. Expected a worktree on the ticket's branch, ` +
2509
+ `feature/${ticketKey}, or feature/${ticketKey}-<slug>.`,
2510
+ ]);
2511
+ }
2512
+ try {
2513
+ await deps.fs.stat(found.path);
2514
+ }
2515
+ catch {
2516
+ return emitFailure(deps, options.json, [`The worktree path for ${ticketKey} is not accessible.`]);
2517
+ }
2518
+ let prompt;
2519
+ try {
2520
+ prompt = await deps.fs.readFile(options.promptFile);
2521
+ }
2522
+ catch {
2523
+ return emitFailure(deps, options.json, [`The prompt file '${options.promptFile}' could not be read.`]);
2524
+ }
2525
+ // BAPI-844: the scope this epic was cut against, read ONLY from the durable
2526
+ // server-returned value on the checkpoint. `process.env.BAPI_INDEX_SCOPE` is
2527
+ // deliberately not consulted — an operator's shell is not the epic's routing
2528
+ // decision, and a pilot ticket has no server-side membership to fall back on,
2529
+ // so an ambient value would silently route this worker's research somewhere
2530
+ // nobody chose. A malformed recorded value stops the spawn with the fixed
2531
+ // configuration error rather than launching an unscoped (canonical) worker.
2532
+ let indexScope;
2533
+ try {
2534
+ indexScope = validateOptionalIndexScope(checkpoint.index_scope_id);
2535
+ }
2536
+ catch {
2537
+ return emitFailure(deps, options.json, [INDEX_SCOPE_CONFIGURATION_ERROR]);
2538
+ }
2539
+ const spawned = await spawnConductEpicAgentTab({
2540
+ ticketKey,
2541
+ worktreePath: found.path,
2542
+ prompt,
2543
+ agent: options.agent,
2544
+ platform: deps.platform,
2545
+ ...(indexScope === undefined ? {} : { indexScope }),
2546
+ }, deps.spawnTab);
2547
+ if (!spawned.ok)
2548
+ return emitFailure(deps, options.json, [spawned.error]);
2549
+ // Counters advance only AFTER the tab actually opened. Incrementing first
2550
+ // would let a failed spawn consume the caller's respawn budget.
2551
+ const now = deps.now().toISOString();
2552
+ const next = {
2553
+ ...checkpoint,
2554
+ counters: { ...checkpoint.counters },
2555
+ tickets: checkpoint.tickets.map((entry) => ({
2556
+ ...entry,
2557
+ counters: { ...entry.counters },
2558
+ journal: [...entry.journal],
2559
+ })),
2560
+ };
2561
+ next.tickets[index].branch = found.branch;
2562
+ next.tickets[index].counters.sessions_spawned += 1;
2563
+ next.tickets[index] = appendTicketJournal(next.tickets[index], `${now} spawned ${options.agent ?? "claude"} in ${found.branch}`);
2564
+ next.updated_at = now;
2565
+ const written = await writeConductEpicCheckpointAtomic(checkpointPath, next, deps.fs, {
2566
+ skipChmod: deps.platform === "win32",
2567
+ });
2568
+ if (!written.ok)
2569
+ return emitFailure(deps, options.json, [written.error]);
2570
+ return emitSuccess(deps, options.json, {
2571
+ ok: true,
2572
+ epic_key: checkpoint.epic_key,
2573
+ ticket: ticketKey,
2574
+ branch: found.branch,
2575
+ worktree_path: found.path,
2576
+ sessions_spawned: next.tickets[index].counters.sessions_spawned,
2577
+ }, [`Spawned one agent tab for ${ticketKey} in ${found.path}`]);
2578
+ }
2579
+ finally {
2580
+ await releaseAcquired(lock);
2581
+ }
2582
+ }
2583
+ // ---------------------------------------------------------------------------
2584
+ // finish
2585
+ // ---------------------------------------------------------------------------
2586
+ /**
2587
+ * `implement-epic finish` — retire the epic's index scope and wind down.
2588
+ *
2589
+ * BAPI-847: there is nothing to RESTORE. `init` no longer repoints the
2590
+ * repository's `base_branch`, so `finish` has no repository-wide configuration
2591
+ * to put back — it retires the scope the epic was cut against and reports that
2592
+ * scope's lifecycle outcome and nothing else. The checkpoint's
2593
+ * `base_branch_original` remains display-only: it records which base the epic was
2594
+ * cut from, never a value this verb writes anywhere.
2595
+ */
2596
+ export async function runConductEpicFinish(deps, options) {
2597
+ const accessProbe = await resolveAccess(deps);
2598
+ if (!accessProbe.ok)
2599
+ return emitFailure(deps, options.json, [accessProbe.error]);
2600
+ const access = accessProbe.access;
2601
+ const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
2602
+ const read = await readConductEpicCheckpoint(checkpointPath, deps.fs);
2603
+ if (read.kind === "missing") {
2604
+ return emitFailure(deps, options.json, [`No checkpoint exists at ${checkpointPath}.`]);
2605
+ }
2606
+ if (read.kind !== "ok")
2607
+ return emitFailure(deps, options.json, [read.error]);
2608
+ const checkpoint = read.checkpoint;
2609
+ const lock = await acquireConductEpicLock(resolveConductEpicLockPath(checkpointPath), lockRequest(deps), buildConductEpicLockSeams(deps));
2610
+ if (!lock.acquired) {
2611
+ return emitFailure(deps, options.json, [`The epic lock could not be acquired: ${lock.reason}`]);
2612
+ }
2613
+ // BAPI-846: `finish` RETIRES the epic's index scope; it never deletes it. The
2614
+ // scope keeps every Postgres row and every Pinecone namespace and stays
2615
+ // readable for post-mortem for the whole retention window — deletion is always
2616
+ // the scheduled sweep's or an explicit `reclaim`'s.
2617
+ //
2618
+ // Retirement runs INSIDE the lock, before it is released, so a concurrent
2619
+ // `finish` cannot interleave with it. It calls the retire API and never a
2620
+ // namespace or database deletion, and enough local state is retained (the
2621
+ // checkpoint keeps `index_scope_id` and the epoch) that a failed retirement can
2622
+ // simply be retried with `implement-epic retire`.
2623
+ let scopeRetired = null;
2624
+ let scopeRetirementError = null;
2625
+ if (typeof checkpoint.index_scope_id === "string" && checkpoint.index_scope_id.length > 0) {
2626
+ const retirement = await retireScopeWithEpoch(deps, options, access, {
2627
+ scopeId: checkpoint.index_scope_id,
2628
+ checkpoint,
2629
+ leaseEpoch: checkpoint.index_scope_lease_epoch,
2630
+ });
2631
+ scopeRetired = retirement.ok;
2632
+ if (!retirement.ok)
2633
+ scopeRetirementError = retirement.reason;
2634
+ }
2635
+ await releaseAcquired(lock);
2636
+ // A failed retirement does NOT fail `finish`: the lock is already released and
2637
+ // every other wind-down step has happened, so reporting failure would invite a
2638
+ // re-run that redoes work already done. It is surfaced instead, with the one
2639
+ // command that fixes it — and the scope enters retention on lease expiry
2640
+ // regardless.
2641
+ if (scopeRetirementError !== null) {
2642
+ deps.errorLog(`The index scope was not retired: ${scopeRetirementError}. ` +
2643
+ `Retry with \`implement-epic retire ${checkpoint.epic_key}\`.`);
2644
+ }
2645
+ // --- Draft epic-integration PR (BAPI-951) --------------------------------
2646
+ // Wind-down: ensure the PR exists AND request it be marked ready for human
2647
+ // review. Fail-open, alongside scope retirement — a failure here never rolls
2648
+ // back retirement or fails `finish`, which has already released the lock.
2649
+ const integrationPrAdvisory = await ensureEpicIntegrationPrAdvisory(deps, {
2650
+ epicKey: checkpoint.epic_key,
2651
+ epicBranch: checkpoint.epic_branch,
2652
+ baseBranch: checkpoint.base_branch_original,
2653
+ command: "implement-epic finish",
2654
+ requestReady: true,
2655
+ });
2656
+ deps.errorLog(`Integration PR: ${JSON.stringify(integrationPrAdvisory)}`);
2657
+ const summary = {
2658
+ ok: true,
2659
+ epic_key: checkpoint.epic_key,
2660
+ epic_branch: checkpoint.epic_branch,
2661
+ /** `null` when the epic declares no scope; `false` when retirement failed. */
2662
+ scope_retired: scopeRetired,
2663
+ counters: { ...checkpoint.counters },
2664
+ needs_human: checkpoint.needs_human,
2665
+ tickets: checkpoint.tickets.map((ticket) => ({
2666
+ key: ticket.key,
2667
+ status: ticket.status,
2668
+ pr_number: ticket.pr_number,
2669
+ counters: { ...ticket.counters },
2670
+ })),
2671
+ };
2672
+ const humanLines = [
2673
+ `Finished ${checkpoint.epic_key} (${checkpoint.epic_branch})`,
2674
+ `index scope: ${scopeRetired === null
2675
+ ? "none declared"
2676
+ : scopeRetired
2677
+ ? "retired (retention clock started; nothing deleted)"
2678
+ : "NOT retired — see the error above"}`,
2679
+ `iterations: ${checkpoint.counters.iterations} merges: ${checkpoint.counters.merges}`,
2680
+ ...checkpoint.tickets.map((ticket) => ` ${ticket.key} ${ticket.status} PR ${ticket.pr_number ?? "-"} ` +
2681
+ `spawned ${ticket.counters.sessions_spawned} plans ${ticket.counters.plan_generations_observed} ` +
2682
+ `merges ${ticket.counters.merge_attempts}`),
2683
+ `needs_human: ${checkpoint.needs_human === null ? "none" : checkpoint.needs_human.reason}`,
2684
+ ];
2685
+ return emitSuccess(deps, options.json, summary, humanLines);
2686
+ }
2687
+ // ---------------------------------------------------------------------------
2688
+ // Index-scope lifecycle verbs (BAPI-846)
2689
+ // ---------------------------------------------------------------------------
2690
+ //
2691
+ // `recover`, `retire`, and `reclaim` all reach the authenticated Bridge API and
2692
+ // nothing else. There is deliberately no local Pinecone client, no SQL, and no
2693
+ // deletion path in this process: the server owns every destructive decision, and
2694
+ // a CLI that could delete directly would be a second authority with none of the
2695
+ // server's fencing, locking, or blocker checks.
2696
+ //
2697
+ // Each one persists the server's returned fencing epoch into the protected
2698
+ // checkpoint — never into argv or stdout — so a later heartbeat or retirement
2699
+ // uses the generation the server actually minted.
2700
+ /**
2701
+ * Resolve which scope a lifecycle verb targets.
2702
+ *
2703
+ * `--scope` wins when supplied; otherwise the epic's own scope is read from the
2704
+ * checkpoint. The explicit flag exists for the case the discovery surface is FOR:
2705
+ * a crashed epic whose local checkpoint is gone or was never written, whose scope
2706
+ * an operator found on `status`.
2707
+ */
2708
+ async function resolveLifecycleScope(deps, options, checkpointPath) {
2709
+ if (options.scope !== undefined) {
2710
+ // An explicitly named scope carries no local lease state, so any epoch must
2711
+ // come from the server. Reading one from an unrelated checkpoint would send a
2712
+ // generation that belongs to a different scope.
2713
+ return { ok: true, scopeId: options.scope, checkpoint: null, leaseEpoch: null };
2714
+ }
2715
+ const read = await readConductEpicCheckpoint(checkpointPath, deps.fs);
2716
+ if (read.kind === "missing") {
2717
+ return {
2718
+ ok: false,
2719
+ reason: `No checkpoint exists at ${checkpointPath}. ` +
2720
+ "Pass --scope <id> to target a scope directly (see `implement-epic status`).",
2721
+ };
2722
+ }
2723
+ if (read.kind !== "ok")
2724
+ return { ok: false, reason: read.error };
2725
+ const scopeId = read.checkpoint.index_scope_id;
2726
+ if (typeof scopeId !== "string" || scopeId.length === 0) {
2727
+ return { ok: false, reason: `${options.epicKey} declares no index scope.` };
2728
+ }
2729
+ return {
2730
+ ok: true,
2731
+ scopeId,
2732
+ checkpoint: read.checkpoint,
2733
+ leaseEpoch: read.checkpoint.index_scope_lease_epoch,
2734
+ };
2735
+ }
2736
+ /**
2737
+ * Persist the server's authoritative fencing epoch into the checkpoint.
2738
+ *
2739
+ * Best-effort by design: the lifecycle call already succeeded on the server, and
2740
+ * failing the command because a local cache write failed would report a
2741
+ * successful recovery as a failure. The next `status` re-reads the epoch anyway.
2742
+ */
2743
+ async function persistScopeLeaseEpoch(deps, checkpointPath, checkpoint, leaseEpoch) {
2744
+ if (checkpoint === null || leaseEpoch === null)
2745
+ return;
2746
+ if (checkpoint.index_scope_lease_epoch === leaseEpoch)
2747
+ return;
2748
+ const next = {
2749
+ ...checkpoint,
2750
+ index_scope_lease_epoch: leaseEpoch,
2751
+ updated_at: deps.now().toISOString(),
2752
+ };
2753
+ await writeConductEpicCheckpointAtomic(checkpointPath, next, deps.fs, {
2754
+ skipChmod: deps.platform === "win32",
2755
+ });
2756
+ }
2757
+ /** `implement-epic recover` — take a new ownership generation for a scope. */
2758
+ export async function runConductEpicRecover(deps, options) {
2759
+ const accessProbe = await resolveAccess(deps);
2760
+ if (!accessProbe.ok)
2761
+ return emitFailure(deps, options.json, [accessProbe.error]);
2762
+ const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
2763
+ const target = await resolveLifecycleScope(deps, options, checkpointPath);
2764
+ if (!target.ok)
2765
+ return emitFailure(deps, options.json, [target.reason]);
2766
+ const recovered = await recoverIndexScope(accessProbe.access, { scopeId: target.scopeId }, deps.fetchImpl);
2767
+ if (!recovered.ok) {
2768
+ return emitFailure(deps, options.json, [
2769
+ `The index scope could not be recovered: ${recovered.error}`,
2770
+ ]);
2771
+ }
2772
+ await persistScopeLeaseEpoch(deps, checkpointPath, target.checkpoint, recovered.value.lease_epoch);
2773
+ return emitSuccess(deps, options.json, {
2774
+ ok: true,
2775
+ epic_key: options.epicKey,
2776
+ scope_id: recovered.value.scope_id,
2777
+ lifecycle_state: recovered.value.lifecycle_state,
2778
+ lease_epoch: recovered.value.lease_epoch,
2779
+ lease_expires_at: recovered.value.lease_expires_at,
2780
+ }, [
2781
+ `Recovered index scope ${recovered.value.scope_id}.`,
2782
+ ` lifecycle: ${recovered.value.lifecycle_state ?? "unknown"}`,
2783
+ ` lease epoch: ${recovered.value.lease_epoch ?? "unknown"} (previous owners are now fenced)`,
2784
+ ` lease expires: ${recovered.value.lease_expires_at ?? "unknown"}`,
2785
+ ]);
2786
+ }
2787
+ /**
2788
+ * `implement-epic catch-up` — replay a scope's MISSED merge observations.
2789
+ *
2790
+ * The verb that rescues an ALREADY-stranded epic. Row 5 of `implement-epic` calls
2791
+ * the same endpoint automatically when `status` reports `unobserved_advance`, so
2792
+ * the common case self-heals on a tick; this verb exists for the epic that is
2793
+ * stranded right now and should not have to wait for one.
2794
+ *
2795
+ * It is a guarded catch-up, and the wording here is deliberate everywhere it
2796
+ * appears: it replays verified merge evidence through the server's own AC-10
2797
+ * gate. It cannot force anything, and no flag will ever let it.
2798
+ */
2799
+ export async function runConductEpicCatchUp(deps, options) {
2800
+ const accessProbe = await resolveAccess(deps);
2801
+ if (!accessProbe.ok)
2802
+ return emitFailure(deps, options.json, [accessProbe.error]);
2803
+ const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
2804
+ const target = await resolveLifecycleScope(deps, options, checkpointPath);
2805
+ if (!target.ok)
2806
+ return emitFailure(deps, options.json, [target.reason]);
2807
+ const result = await catchUpIndexScope(accessProbe.access, { scopeId: target.scopeId }, deps.fetchImpl);
2808
+ if (!result.ok) {
2809
+ return emitFailure(deps, options.json, [
2810
+ `The guarded catch-up could not run: ${result.error}`,
2811
+ ]);
2812
+ }
2813
+ const value = result.value;
2814
+ const payload = {
2815
+ ok: value.ok,
2816
+ epic_key: options.epicKey,
2817
+ scope_id: target.scopeId,
2818
+ outcome: value.outcome,
2819
+ reason: value.reason,
2820
+ required_commit_sha: value.required_commit_sha,
2821
+ parse_scheduled: value.parse_scheduled,
2822
+ };
2823
+ // BAPI-963. Catch-up says "a re-parse is scheduled", but NOTHING in the pilot
2824
+ // dispatches it: the queue is swept by the APScheduler job in `worker.py`'s job
2825
+ // set, and the web dyno runs with `DISABLE_SCHEDULER=true`. During the BAPI-943
2826
+ // pilot the first catch-up worked only because an unrelated v2 run's server
2827
+ // happened to be sweeping; had that process stayed dead, the scope would have
2828
+ // sat at `pending` forever with no diagnostic. Read the dispatcher only when a
2829
+ // parse was actually scheduled, so an ordinary catch-up adds no request.
2830
+ let dispatcher = null;
2831
+ if (value.ok && value.parse_scheduled) {
2832
+ const health = await getParseDispatcherHealth(accessProbe.access, deps.fetchImpl);
2833
+ // Advisory: a failed read leaves the successful catch-up successful and simply
2834
+ // says nothing, rather than claiming a dispatcher is missing.
2835
+ if (health.ok)
2836
+ dispatcher = health.value;
2837
+ }
2838
+ const lines = renderCatchUpLines(value, target.scopeId, dispatcher);
2839
+ // --- Draft epic-integration PR (BAPI-951) --------------------------------
2840
+ // Retry point for deferred provisioning: only after a SUCCESSFUL reconciliation
2841
+ // (`value.ok`), and only when the checkpoint carries the epic/base branches this
2842
+ // needs (absent for an explicit `--scope` target with no local checkpoint).
2843
+ if (value.ok && target.checkpoint !== null) {
2844
+ const formatted = await ensureEpicIntegrationPrAdvisory(deps, {
2845
+ epicKey: options.epicKey,
2846
+ epicBranch: target.checkpoint.epic_branch,
2847
+ baseBranch: target.checkpoint.base_branch_original,
2848
+ command: "implement-epic catch-up",
2849
+ });
2850
+ lines.push(`integration pr: ${JSON.stringify(formatted)}`);
2851
+ }
2852
+ return value.ok
2853
+ ? emitSuccess(deps, options.json, payload, lines)
2854
+ : emitFailure(deps, options.json, lines, payload);
2855
+ }
2856
+ /**
2857
+ * Render one guarded catch-up outcome as distinct, bounded operator lines.
2858
+ *
2859
+ * Every outcome gets its own sentence rather than collapsing into "it did not
2860
+ * work": the three successes call for different next steps (nothing, poll, poll),
2861
+ * and the refusals call for genuinely different human action — a controlled AC-10
2862
+ * block needs the BRANCH fixed, a history-limit refusal needs a fresh scope, and
2863
+ * an `unavailable` needs the provider to come back.
2864
+ */
2865
+ function renderCatchUpLines(value, scopeId, dispatcher = null) {
2866
+ const pin = ` Required commit: ${value.required_commit_sha ?? "unknown"}`;
2867
+ // BAPI-963: appended only on POSITIVE evidence that nothing is sweeping. An
2868
+ // `unavailable` observation says nothing, because an advisory that cries wolf
2869
+ // on a network blip is one an operator learns to skip.
2870
+ const waiting = dispatcher !== null && dispatcher.observation === "absent"
2871
+ ? [
2872
+ " WAITING ON: a live parse dispatcher. No dispatcher was observed, so the",
2873
+ " scheduled re-parse cannot begin until the `worker:` dyno is sweeping the parse queue.",
2874
+ ]
2875
+ : [];
2876
+ switch (value.outcome) {
2877
+ case "repaired":
2878
+ return [
2879
+ `Guarded catch-up repaired index scope ${scopeId}.`,
2880
+ " Every replayed merge passed the AC-10 gate.",
2881
+ pin,
2882
+ value.parse_scheduled
2883
+ ? " A re-parse is scheduled. Poll `implement-epic status` until freshness reads `fresh`."
2884
+ : " No re-parse was scheduled; poll `implement-epic status` for the scope's own state.",
2885
+ ...waiting,
2886
+ ];
2887
+ case "already_current":
2888
+ return [
2889
+ `Index scope ${scopeId} is already current. Nothing was written.`,
2890
+ pin,
2891
+ ];
2892
+ case "parse_scheduled":
2893
+ return [
2894
+ `Index scope ${scopeId} was already pinned at its branch head; no advance was needed.`,
2895
+ value.parse_scheduled
2896
+ ? " A re-parse is scheduled. Poll `implement-epic status` until freshness reads `fresh`."
2897
+ : ` No re-parse was scheduled (${value.reason ?? "the scope is not schedulable right now"}).`,
2898
+ pin,
2899
+ ...waiting,
2900
+ ];
2901
+ case "race_lost":
2902
+ return [
2903
+ `Another observation already owns this scope's target; the catch-up did nothing.`,
2904
+ " This is the safe outcome of a race, not a failure. Poll `implement-epic status`.",
2905
+ pin,
2906
+ ];
2907
+ case "blocked":
2908
+ return [
2909
+ `Guarded catch-up REFUSED to advance index scope ${scopeId}.`,
2910
+ ` Reason: ${value.reason ?? "the server refused this branch advance"}`,
2911
+ " Both commit watermarks are unchanged and no parse was scheduled.",
2912
+ " A human must resolve the branch itself; there is no override.",
2913
+ pin,
2914
+ ];
2915
+ case "history_limit_exceeded":
2916
+ return [
2917
+ `Index scope ${scopeId} is too far behind its epic branch to replay.`,
2918
+ " Refusing rather than replaying unbounded history. Cut a fresh scope.",
2919
+ pin,
2920
+ ];
2921
+ case "refused_lifecycle":
2922
+ return [
2923
+ `Index scope ${scopeId} cannot take a catch-up in its current state.`,
2924
+ ` Reason: ${value.reason ?? "unknown"}`,
2925
+ pin,
2926
+ ];
2927
+ case "unavailable":
2928
+ return [
2929
+ `The guarded catch-up could not read the epic branch for scope ${scopeId}.`,
2930
+ ` Reason: ${value.reason ?? "unknown"}`,
2931
+ " An unread branch is never a current one. Retry once the provider recovers.",
2932
+ pin,
2933
+ ];
2934
+ default:
2935
+ return [
2936
+ `The guarded catch-up did not repair index scope ${scopeId}.`,
2937
+ ` Outcome: ${value.outcome ?? "unknown"} Reason: ${value.reason ?? "unknown"}`,
2938
+ pin,
2939
+ ];
2940
+ }
2941
+ }
2942
+ /** `implement-epic retire` — start the retention clock; delete nothing. */
2943
+ export async function runConductEpicRetire(deps, options) {
2944
+ const accessProbe = await resolveAccess(deps);
2945
+ if (!accessProbe.ok)
2946
+ return emitFailure(deps, options.json, [accessProbe.error]);
2947
+ const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
2948
+ const target = await resolveLifecycleScope(deps, options, checkpointPath);
2949
+ if (!target.ok)
2950
+ return emitFailure(deps, options.json, [target.reason]);
2951
+ const outcome = await retireScopeWithEpoch(deps, options, accessProbe.access, target);
2952
+ if (!outcome.ok)
2953
+ return emitFailure(deps, options.json, [outcome.reason]);
2954
+ return emitSuccess(deps, options.json, {
2955
+ ok: true,
2956
+ epic_key: options.epicKey,
2957
+ scope_id: outcome.state.scope_id,
2958
+ lifecycle_state: outcome.state.lifecycle_state,
2959
+ lease_epoch: outcome.state.lease_epoch,
2960
+ already_retired: outcome.state.already_retired,
2961
+ }, [
2962
+ outcome.state.already_retired
2963
+ ? `Index scope ${outcome.state.scope_id} was already retired; retention clock unchanged.`
2964
+ : `Retired index scope ${outcome.state.scope_id}. Nothing was deleted.`,
2965
+ " The scope stays readable for post-mortem for the whole retention window.",
2966
+ ]);
2967
+ }
2968
+ /**
2969
+ * Retire a scope, resolving the fencing epoch the server currently holds.
2970
+ *
2971
+ * The epoch is the one thing retirement needs and the one thing a local
2972
+ * checkpoint can be wrong about — an operator `recover` (or another conductor)
2973
+ * may have superseded it. So a locally-cached epoch is used only as a first
2974
+ * attempt; on a fencing refusal the CURRENT epoch is read from the server's
2975
+ * listing and the retirement is retried ONCE. That is not a retry loop papering
2976
+ * over a race: retirement is idempotent and the second attempt uses an epoch the
2977
+ * server itself just reported.
2978
+ */
2979
+ async function retireScopeWithEpoch(deps, options, access, target) {
2980
+ let epoch = target.leaseEpoch;
2981
+ if (epoch === null) {
2982
+ const current = await lookupScopeEpoch(deps, access, target.scopeId);
2983
+ if (current === null) {
2984
+ return {
2985
+ ok: false,
2986
+ reason: `The current fencing epoch for scope ${target.scopeId} could not be read.`,
2987
+ };
2988
+ }
2989
+ epoch = current;
2990
+ }
2991
+ let retired = await retireIndexScope(access, { scopeId: target.scopeId, leaseEpoch: epoch }, deps.fetchImpl);
2992
+ if (!retired.ok) {
2993
+ const current = await lookupScopeEpoch(deps, access, target.scopeId);
2994
+ if (current !== null && current !== epoch) {
2995
+ epoch = current;
2996
+ retired = await retireIndexScope(access, { scopeId: target.scopeId, leaseEpoch: epoch }, deps.fetchImpl);
2997
+ }
2998
+ }
2999
+ if (!retired.ok) {
3000
+ return { ok: false, reason: `The index scope could not be retired: ${retired.error}` };
3001
+ }
3002
+ const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
3003
+ await persistScopeLeaseEpoch(deps, checkpointPath, target.checkpoint, retired.value.lease_epoch);
3004
+ return { ok: true, state: retired.value };
3005
+ }
3006
+ /** Read one scope's CURRENT fencing epoch from the authoritative listing. */
3007
+ async function lookupScopeEpoch(deps, access, scopeId) {
3008
+ const listing = await getIndexScopeLifecycle(access, deps.fetchImpl);
3009
+ if (!listing.ok)
3010
+ return null;
3011
+ const entry = listing.value.scopes.find((scope) => scope.scope_id === scopeId);
3012
+ return entry?.lease_epoch ?? null;
3013
+ }
3014
+ /** `implement-epic reclaim` — ask the server to schedule the scope's teardown. */
3015
+ export async function runConductEpicReclaim(deps, options) {
3016
+ const accessProbe = await resolveAccess(deps);
3017
+ if (!accessProbe.ok)
3018
+ return emitFailure(deps, options.json, [accessProbe.error]);
3019
+ const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
3020
+ const target = await resolveLifecycleScope(deps, options, checkpointPath);
3021
+ if (!target.ok)
3022
+ return emitFailure(deps, options.json, [target.reason]);
3023
+ const scheduled = await reclaimIndexScope(accessProbe.access, { scopeId: target.scopeId, overrideRetention: options.overrideRetention }, deps.fetchImpl);
3024
+ if (!scheduled.ok) {
3025
+ // A refusal names the BLOCKERS the server evaluated, so an operator learns
3026
+ // that a parse is running rather than that "reclaim failed".
3027
+ const blockers = scheduled.blockers ?? [];
3028
+ const reasons = [`The index scope could not be reclaimed: ${scheduled.error}`];
3029
+ if (blockers.length > 0)
3030
+ reasons.push(` blocked by: ${blockers.join(", ")}`);
3031
+ return emitFailure(deps, options.json, reasons, {
3032
+ epic_key: options.epicKey,
3033
+ scope_id: target.scopeId,
3034
+ blockers,
3035
+ });
3036
+ }
3037
+ return emitSuccess(deps, options.json, {
3038
+ ok: true,
3039
+ epic_key: options.epicKey,
3040
+ scope_id: scheduled.value.scope_id ?? target.scopeId,
3041
+ scheduled: scheduled.value.scheduled,
3042
+ }, [
3043
+ `Scheduled reclamation of index scope ${scheduled.value.scope_id ?? target.scopeId}.`,
3044
+ " This is SCHEDULED, not done — the teardown waits out Pinecone's",
3045
+ " consistency window. Run `implement-epic status --json` to see it reach",
3046
+ " `reclaimed`.",
3047
+ ]);
3048
+ }
3049
+ /**
3050
+ * `implement-epic scopes` — the repository-wide index-scope inventory (BAPI-963).
3051
+ *
3052
+ * `--help` promised this listing under `status` and no invocation delivered it:
3053
+ * `implement-epic scopes` answered `Unknown verb` and `status <EPIC> --scopes`
3054
+ * answered `Unknown flag`, so an operator asking exactly the question the help
3055
+ * advertised was sent to SQL. The capability already existed — `status` pushes a
3056
+ * stranded-scope warning to stderr — only its documented entry point did not.
3057
+ *
3058
+ * Repository-scoped by construction: it resolves access, reads the lifecycle
3059
+ * listing ONCE, and takes no epic key, no checkpoint, and no lease.
3060
+ */
3061
+ export async function runConductEpicScopes(deps, options) {
3062
+ const accessProbe = await resolveAccess(deps);
3063
+ if (!accessProbe.ok)
3064
+ return emitFailure(deps, options.json, [accessProbe.error]);
3065
+ const listing = await getIndexScopeLifecycle(accessProbe.access, deps.fetchImpl);
3066
+ if (!listing.ok) {
3067
+ return emitFailure(deps, options.json, [
3068
+ `The index-scope inventory could not be read: ${listing.error}`,
3069
+ ]);
3070
+ }
3071
+ return emitSuccess(deps, options.json, {
3072
+ ok: true,
3073
+ repo_name: accessProbe.access.repoName,
3074
+ retention_seconds: listing.value.retention_seconds,
3075
+ // The COMPLETE listing, unfiltered. `status` narrows deliberately; this verb
3076
+ // exists because that narrowing is the wrong answer to "what exists?".
3077
+ scopes: listing.value.scopes,
3078
+ }, renderIndexScopeInventoryLines(listing.value.scopes, accessProbe.access.repoName));
3079
+ }
3080
+ /**
3081
+ * Render the FULL scope inventory for `implement-epic scopes` (BAPI-963).
3082
+ *
3083
+ * Deliberately unlike {@link renderStrandedScopeLines}, which stays narrow: that
3084
+ * one prints only actionable wreckage on a status tick an operator reads every
3085
+ * few minutes, and widening it would turn a warning into wallpaper. This one
3086
+ * answers a question the operator asked on purpose, so it hides nothing —
3087
+ * reclaimed tombstones included, since "it is already gone" is an answer.
3088
+ */
3089
+ function renderIndexScopeInventoryLines(scopes, repoName) {
3090
+ if (scopes.length === 0) {
3091
+ return [`${repoName} owns no index scopes.`];
3092
+ }
3093
+ const lines = [`${repoName} owns ${scopes.length} index scope(s):`];
3094
+ for (const scope of scopes) {
3095
+ lines.push(` ${scope.scope_id} ${scope.lifecycle_state ?? "unknown"} ` +
3096
+ `branch=${scope.feature_branch ?? "unknown"} ` +
3097
+ `lease=${scope.lease_valid ? "live" : "expired"} ` +
3098
+ `retention=${scope.retention_elapsed ? "elapsed" : (scope.retention_deadline ?? "none")} ` +
3099
+ `recoverable=${scope.recoverable ? "yes" : "no"}`);
3100
+ if (scope.blockers.length > 0) {
3101
+ lines.push(` blocked by: ${scope.blockers.join(", ")}`);
3102
+ }
3103
+ }
3104
+ return lines;
3105
+ }
3106
+ // ---------------------------------------------------------------------------
3107
+ // Entry point
3108
+ // ---------------------------------------------------------------------------
3109
+ /**
3110
+ * Entry point for the packaged `implement-epic` subcommand.
3111
+ *
3112
+ * Returns a numeric exit code and never throws for user-facing failure: `0` for
3113
+ * help and success, `1` for everything else with a concise stderr reason.
3114
+ *
3115
+ * S4 (BAPI-1080): renamed from `runConductEpicCli` (the packaged positional
3116
+ * command moved from `conduct-epic` to `implement-epic`). Internal
3117
+ * `ConductEpic*` type names, the checkpoint schema, `CONDUCT_EPIC_*`
3118
+ * constants/env vars, and `~/.config/bridge/conduct/` storage paths are
3119
+ * unchanged — they are owned by later slices.
3120
+ */
3121
+ export async function runImplementEpicCli(argv, overrides = {}) {
3122
+ const deps = { ...createDefaultConductEpicDeps(), ...overrides };
3123
+ const parsed = parseConductEpicArgs(argv);
3124
+ if (parsed.status === "help") {
3125
+ deps.log(parsed.usage);
3126
+ return 0;
3127
+ }
3128
+ if (parsed.status === "error") {
3129
+ deps.errorLog(parsed.message);
3130
+ // `--json` is parsed alongside the failing token, so a malformed invocation
3131
+ // cannot be trusted to have set it. Failures from the parser are therefore
3132
+ // reported on stderr only, and the exit code carries the outcome.
3133
+ return 1;
3134
+ }
3135
+ const options = parsed.options;
3136
+ switch (options.verb) {
3137
+ case "scopes":
3138
+ return runConductEpicScopes(deps, options);
3139
+ case "init":
3140
+ return runConductEpicInit(deps, options);
3141
+ case "status":
3142
+ return runConductEpicStatus(deps, options);
3143
+ case "checkpoint-set":
3144
+ return runConductEpicCheckpointSet(deps, options);
3145
+ case "spawn":
3146
+ return runConductEpicSpawn(deps, options);
3147
+ case "finish":
3148
+ return runConductEpicFinish(deps, options);
3149
+ case "recover":
3150
+ return runConductEpicRecover(deps, options);
3151
+ case "retire":
3152
+ return runConductEpicRetire(deps, options);
3153
+ case "reclaim":
3154
+ return runConductEpicReclaim(deps, options);
3155
+ case "catch-up":
3156
+ return runConductEpicCatchUp(deps, options);
3157
+ }
3158
+ }