@bridge_gpt/mcp-server 0.2.37 → 0.2.39

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 (91) hide show
  1. package/README.md +193 -16
  2. package/build/agent-capabilities/probe-context.js +2 -1
  3. package/build/agent-launchers/claude-executor-adapter.js +392 -0
  4. package/build/agent-launchers/executor-adapter-inspection.js +163 -0
  5. package/build/agent-launchers/executor-adapter-registry.js +90 -0
  6. package/build/agent-launchers/executor-adapter.js +136 -0
  7. package/build/agent-registry.js +28 -0
  8. package/build/agents.generated.js +1 -1
  9. package/build/claude-login.js +85 -0
  10. package/build/claude-user-config-doctor.js +59 -33
  11. package/build/commands.generated.js +12 -11
  12. package/build/conduct-epic/bridge-client.js +345 -0
  13. package/build/conduct-epic/checkpoint-store.js +423 -0
  14. package/build/conduct-epic/cli.js +1732 -0
  15. package/build/conduct-epic/lock.js +302 -0
  16. package/build/conduct-epic/pr-state.js +197 -0
  17. package/build/conduct-epic/spawn.js +101 -0
  18. package/build/conductor/bridge-api-client.js +37 -2
  19. package/build/conductor/doctor.js +11 -1
  20. package/build/conductor/install-doctor.js +184 -10
  21. package/build/conductor-bin.js +7 -7
  22. package/build/credential-store.js +10 -4
  23. package/build/credentials-cli.js +34 -19
  24. package/build/docs.generated.js +1 -1
  25. package/build/doctor.js +579 -88
  26. package/build/executor/agent-identity.js +32 -0
  27. package/build/executor/cli.js +50 -39
  28. package/build/executor/deps.js +15 -1
  29. package/build/executor/env.js +56 -45
  30. package/build/executor/index.js +9 -1
  31. package/build/executor/install-preflight.js +138 -0
  32. package/build/executor/job-errors.js +200 -0
  33. package/build/executor/job-runner.js +619 -268
  34. package/build/executor/observation.js +165 -0
  35. package/build/executor/permissions.js +163 -36
  36. package/build/executor/platform.js +54 -0
  37. package/build/executor/preflight.js +175 -67
  38. package/build/executor/process.js +39 -7
  39. package/build/executor/runner.js +19 -0
  40. package/build/executor/service-lifecycle.js +269 -0
  41. package/build/executor/service-unit.js +121 -12
  42. package/build/executor/stale-artifacts.js +70 -0
  43. package/build/executor/test-clock.js +188 -24
  44. package/build/executor/worker-command.js +22 -58
  45. package/build/executor/worker-log.js +82 -0
  46. package/build/executor/worktree-lock.js +264 -0
  47. package/build/index.js +527 -357
  48. package/build/install-bridge-conductor.js +376 -38
  49. package/build/install-bridge.js +414 -114
  50. package/build/install-doctor.js +13 -0
  51. package/build/install-reexec.js +5 -3
  52. package/build/mcp-install-state.js +130 -0
  53. package/build/mcp-profile.js +11 -2
  54. package/build/mcp-provisioning.js +15 -0
  55. package/build/merge-pull-request.js +562 -0
  56. package/build/phase-result-artifacts.js +450 -0
  57. package/build/pipeline-orchestrator.js +4 -0
  58. package/build/pipeline-utils.js +16 -0
  59. package/build/pipelines.generated.js +7 -7
  60. package/build/plane/preflight.js +18 -14
  61. package/build/plane/supervisor.js +8 -1
  62. package/build/project-root.js +34 -0
  63. package/build/readme.generated.js +1 -1
  64. package/build/run-unit-tests-launcher.js +36 -9
  65. package/build/setup-epic.js +57 -4
  66. package/build/sfcc/ocapi-shape.js +23 -4
  67. package/build/sfcc/permissions.js +25 -6
  68. package/build/sfcc/read-body.js +92 -0
  69. package/build/sfcc/read-projection.js +6 -2
  70. package/build/sfcc/reads-custom-object-def.js +33 -21
  71. package/build/sfcc/reads-site-preference.js +20 -7
  72. package/build/sfcc/reads-system-object.js +11 -5
  73. package/build/sfcc/register.js +61 -23
  74. package/build/sfcc/registration-inventory.js +89 -0
  75. package/build/sfcc/setup-status.js +18 -34
  76. package/build/sfcc/tool-wrapper.js +294 -17
  77. package/build/sfcc/write-grants.js +33 -1
  78. package/build/sfcc/write-guard.js +41 -12
  79. package/build/sfcc/write-result.js +16 -7
  80. package/build/sfcc/writes-custom-object-def.js +12 -4
  81. package/build/sfcc/writes-site-preference.js +6 -1
  82. package/build/sfcc/writes-system-object.js +11 -2
  83. package/build/sfcc/writes.js +13 -8
  84. package/build/start-tickets-prereqs.js +25 -15
  85. package/build/start-tickets.js +123 -21
  86. package/build/version.generated.js +1 -1
  87. package/build/worktree-core.js +9 -3
  88. package/docs/install/mcp-tool-integrations.md +54 -9
  89. package/docs/install/sfcc-integration.md +71 -24
  90. package/package.json +3 -3
  91. package/build/executor/worker-config-isolation.js +0 -287
@@ -0,0 +1,1732 @@
1
+ /**
2
+ * The packaged `conduct-epic` CLI (BAPI-803).
3
+ *
4
+ * `/conduct-epic` (BAPI-804) is prose. Prose cannot be unit-tested, and the
5
+ * guarantees the loop depends on — that the epic branch was created from the
6
+ * right base, that a checkpoint write is atomic, that two sessions cannot edit
7
+ * one checkpoint, that a `MERGED` PR is visible, that `auto_merge_enabled` is on
8
+ * before the first ticket is dispatched — are exactly the kind that fail
9
+ * silently when they live in an instruction file. So they live here, behind five
10
+ * verbs the loop calls and this file's tests pin:
11
+ *
12
+ * init | status | checkpoint set | finish | spawn
13
+ *
14
+ * Design rules that hold across all five:
15
+ *
16
+ * - **Nothing creates or mutates an `epic_run`.** This CLI drives a LOCAL loop.
17
+ * The one epic-run call it makes (`getEpicRunState`) is a read whose only
18
+ * purpose is to REFUSE when the server-side v2 reconciler is already
19
+ * conducting the same epic — two conductors on one epic is the failure mode.
20
+ * - **Every process/network/filesystem boundary is injected** through
21
+ * {@link ConductEpicDeps}, so the whole surface is testable without a real
22
+ * `gh`, terminal, home directory, or Bridge server.
23
+ * - **`init` accumulates.** Its preflight runs every independent check and
24
+ * prints every failure in one pass. Failing on the first one makes an operator
25
+ * discover a five-problem environment five invocations at a time.
26
+ * - **`status` fails open per probe, `status` never takes the lock.** A `gh`
27
+ * outage must not stop the loop from seeing its checkpoint; a read-only
28
+ * surface that took a lock would deadlock against the verb that needs it.
29
+ * - **Credentials resolve only through `resolveConductorBridgeApiAccess`** and
30
+ * never enter argv, stdout, stderr, a journal line, or an error string.
31
+ */
32
+ import { execFile } from "node:child_process";
33
+ import { promises as nodeFs } from "node:fs";
34
+ import os from "node:os";
35
+ import path from "node:path";
36
+ import { validateBranchName } from "../base-ref.js";
37
+ import { parseDoneGateConfig } from "../conductor/done-gate.js";
38
+ import { resolveConductorBridgeApiAccess, } from "../conductor/bridge-api-client.js";
39
+ import { runGhCommand } from "../conductor/pr-discovery.js";
40
+ import { getDefaultSpawnTerminalTabForPlatform, detectTerminal, createDefaultStartTicketsDeps, } from "../start-tickets.js";
41
+ import { resolveWorktrunkBinary } from "../start-tickets-prereqs.js";
42
+ import { resolveRequiredStartTicketsRepoName } from "../start-tickets-repo.js";
43
+ import { getConfigFieldBaseBranch, getConductorReadiness, getEffectiveSupervisorConfig, getEffectiveSupervisorSetup, getEpicRunState, getIndexBranch, getParseStatus, getPrReviewStatus, pollCiChecks, putSupervisorConfigDefaults, repointIndexBranch, resolveCiChecks, restoreIndexBranch, } from "./bridge-client.js";
44
+ import { appendTicketJournal, createInitialConductEpicCheckpoint, readConductEpicCheckpoint, resolveConductEpicCheckpointPath, resolveConductEpicLockPath, writeConductEpicCheckpointAtomic, CONDUCT_EPIC_TICKET_STATUSES, } from "./checkpoint-store.js";
45
+ import { acquireConductEpicLock, inspectConductEpicLock, isConductEpicLockOwnerAlive, } from "./lock.js";
46
+ import { discoverConductEpicPrState, discoverTicketWorktree, parseGitWorktreePorcelain, } from "./pr-state.js";
47
+ import { spawnConductEpicAgentTab, CONDUCT_EPIC_AGENTS, } from "./spawn.js";
48
+ /** Epic and ticket keys accepted by every verb. */
49
+ export const CONDUCT_EPIC_KEY_PATTERN = /^[A-Z]+-[0-9]+$/;
50
+ /** The five verb families. `checkpoint set` is two tokens, one verb. */
51
+ export const CONDUCT_EPIC_VERBS = ["init", "status", "checkpoint set", "finish", "spawn"];
52
+ /** Per-ticket fields `checkpoint set` may assign. */
53
+ const TICKET_FIELDS = [
54
+ "status",
55
+ "branch",
56
+ "pr_number",
57
+ "spawned_at",
58
+ "respawns",
59
+ "conflict_attempts",
60
+ "counters.sessions_spawned",
61
+ "counters.plan_generations_observed",
62
+ "counters.merge_attempts",
63
+ ];
64
+ /** Top-level fields `checkpoint set` may assign. */
65
+ const TOP_LEVEL_FIELDS = ["needs_human", "counters.iterations", "counters.merges"];
66
+ /** Build the production dependency set. */
67
+ export function createDefaultConductEpicDeps() {
68
+ const runCommand = (file, args, options) => new Promise((resolve) => {
69
+ execFile(file, args, {
70
+ cwd: options?.cwd,
71
+ // Git porcelain output for a many-worktree checkout can be large.
72
+ maxBuffer: 16 * 1024 * 1024,
73
+ encoding: "utf-8",
74
+ timeout: options?.timeoutMs,
75
+ // Explicit: arguments are a list, never a concatenated shell string.
76
+ shell: false,
77
+ }, (error, stdout, stderr) => {
78
+ const code = error?.code;
79
+ resolve({
80
+ stdout: stdout ?? "",
81
+ stderr: stderr ?? "",
82
+ exitCode: typeof code === "number" ? code : error ? 1 : 0,
83
+ });
84
+ });
85
+ });
86
+ const spawner = getDefaultSpawnTerminalTabForPlatform(process.platform);
87
+ const startTicketsDeps = createDefaultStartTicketsDeps();
88
+ return {
89
+ runCommand,
90
+ runGh: (args, options) => runGhCommand(args, options ?? {}),
91
+ spawnTab: (shellCommand, context) => spawner(startTicketsDeps, detectTerminal(undefined, process.env), shellCommand, context),
92
+ fetchImpl: globalThis.fetch,
93
+ fs: {
94
+ mkdir: (dirPath, options) => nodeFs.mkdir(dirPath, options),
95
+ readFile: (filePath) => nodeFs.readFile(filePath, "utf-8"),
96
+ writeFile: (filePath, data, options) => nodeFs.writeFile(filePath, data, { encoding: "utf-8", mode: options?.mode }),
97
+ // Real exclusive create: `wx` fails EEXIST in the kernel, so two racing
98
+ // callers cannot both believe they created the file. Never reconstruct
99
+ // this from `stat` + `writeFile`.
100
+ writeFileExclusive: async (filePath, data, options) => {
101
+ const handle = await nodeFs.open(filePath, "wx", options?.mode);
102
+ try {
103
+ await handle.writeFile(data, "utf-8");
104
+ }
105
+ finally {
106
+ await handle.close();
107
+ }
108
+ },
109
+ rename: (fromPath, toPath) => nodeFs.rename(fromPath, toPath),
110
+ chmod: (filePath, mode) => nodeFs.chmod(filePath, mode),
111
+ unlink: (filePath) => nodeFs.unlink(filePath),
112
+ stat: (filePath) => nodeFs.stat(filePath),
113
+ },
114
+ now: () => new Date(),
115
+ env: process.env,
116
+ homedir: os.homedir,
117
+ hostname: os.hostname,
118
+ platform: process.platform,
119
+ cwd: process.cwd(),
120
+ pid: process.pid,
121
+ isProcessAlive: isConductEpicLockOwnerAlive,
122
+ log: (m) => console.log(m),
123
+ errorLog: (m) => console.error(m),
124
+ resolveAccess: resolveConductorBridgeApiAccess,
125
+ resolveRepoName: resolveRequiredStartTicketsRepoName,
126
+ };
127
+ }
128
+ // ---------------------------------------------------------------------------
129
+ // Usage + argument parsing
130
+ // ---------------------------------------------------------------------------
131
+ /** User-facing usage text for the packaged `conduct-epic` subcommand. */
132
+ export function getConductEpicUsage() {
133
+ return [
134
+ "Usage:",
135
+ " npx -y @bridge_gpt/mcp-server conduct-epic <verb> [flags]",
136
+ "",
137
+ "Verbs:",
138
+ " init <EPIC> --tickets K1,K2,... [--base-branch <b>] [--checkpoint-path <p>] [--dry-run] [--json]",
139
+ " Run the full preflight, create epic/<EPIC> on origin at the fetched base",
140
+ " tip, repoint the indexed branch, write the checkpoint, and take the lock.",
141
+ " Every preflight failure is printed in one pass; nothing is written unless",
142
+ " all of them pass. --dry-run prints the validated plan and writes nothing.",
143
+ "",
144
+ " status <EPIC> [--checkpoint-path <p>] --json",
145
+ " Print one JSON object describing the in-flight ticket, its worktree, PR,",
146
+ " CI, review, parse, deadline, and lock state. --json is required. A missing",
147
+ " checkpoint exits 0 with checkpoint_exists:false. A failed probe leaves its",
148
+ " sub-object null and is listed in probe_errors; it never fails the command.",
149
+ "",
150
+ " checkpoint set <EPIC> --ticket <KEY> [--field <name> <value>]... [--journal <line>]",
151
+ " [--checkpoint-path <p>]",
152
+ " Apply ABSOLUTE field values (the caller computes n+1 from status).",
153
+ ` Ticket fields: ${TICKET_FIELDS.join(", ")}.`,
154
+ ` Top-level fields: ${TOP_LEVEL_FIELDS.join(", ")}.`,
155
+ "",
156
+ " finish <EPIC> [--checkpoint-path <p>] [--json]",
157
+ " Restore the server's indexed base branch (idempotent), release the owned",
158
+ " lock, and print the final summary. A second finish succeeds.",
159
+ "",
160
+ ` spawn <EPIC> --ticket <KEY> --prompt-file <path> [--agent ${CONDUCT_EPIC_AGENTS.join("|")}]`,
161
+ " [--checkpoint-path <p>] [--json]",
162
+ " Open exactly ONE agent tab in the ticket's worktree running the prompt",
163
+ " file's contents, then increment counters.sessions_spawned and append a",
164
+ " journal line. Respawn and conflict budgets are the CALLER's job.",
165
+ "",
166
+ "Common:",
167
+ " -h, --help Show this help",
168
+ "",
169
+ "State lives outside the repository, at ~/.config/bridge/conduct/<repo>/ ",
170
+ "(honoring XDG_CONFIG_HOME): <EPIC>.json, <EPIC>.json.prev, <EPIC>.lock, and",
171
+ "<EPIC>/prompts/. The directory is 0700 and files are 0600.",
172
+ "",
173
+ "Exit codes: 0 on success (including checkpoint_exists:false and an idempotent",
174
+ "finish); non-zero on any failure, with a one-line reason on stderr. With --json,",
175
+ "stdout is exactly one JSON object carrying ok.",
176
+ ].join("\n");
177
+ }
178
+ function parseError(message) {
179
+ return { status: "error", message };
180
+ }
181
+ /** Which flags each verb accepts. An unknown flag for the verb is rejected. */
182
+ const VERB_FLAGS = {
183
+ init: ["--tickets", "--base-branch", "--checkpoint-path", "--dry-run", "--json"],
184
+ status: ["--checkpoint-path", "--json"],
185
+ "checkpoint-set": ["--ticket", "--field", "--journal", "--checkpoint-path"],
186
+ finish: ["--checkpoint-path", "--json"],
187
+ spawn: ["--ticket", "--prompt-file", "--agent", "--checkpoint-path", "--json"],
188
+ };
189
+ /**
190
+ * Parse and fully validate argv BEFORE any I/O.
191
+ *
192
+ * Rejects unknown verbs, unknown flags, duplicate singleton options, missing
193
+ * values, malformed keys, invalid branch names, and stray positionals. Every one
194
+ * of those is a mistake a caller can make in a generated shell command, and
195
+ * catching them here means a bad invocation never spawns a process, resolves a
196
+ * credential, or touches the checkpoint.
197
+ */
198
+ export function parseConductEpicArgs(argv) {
199
+ if (argv.includes("-h") || argv.includes("--help")) {
200
+ return { status: "help", usage: getConductEpicUsage() };
201
+ }
202
+ if (argv.length === 0)
203
+ return parseError("A verb is required.");
204
+ let verb;
205
+ let rest;
206
+ if (argv[0] === "checkpoint") {
207
+ if (argv[1] !== "set") {
208
+ return parseError("Unknown verb 'checkpoint " + (argv[1] ?? "") + "'. Expected 'checkpoint set'.");
209
+ }
210
+ verb = "checkpoint-set";
211
+ rest = argv.slice(2);
212
+ }
213
+ else if (argv[0] === "init" || argv[0] === "status" || argv[0] === "finish" || argv[0] === "spawn") {
214
+ verb = argv[0];
215
+ rest = argv.slice(1);
216
+ }
217
+ else {
218
+ return parseError(`Unknown verb '${argv[0]}'. Expected one of: ${CONDUCT_EPIC_VERBS.join(", ")}.`);
219
+ }
220
+ const allowed = VERB_FLAGS[verb];
221
+ const options = {
222
+ verb,
223
+ epicKey: "",
224
+ tickets: [],
225
+ fields: [],
226
+ dryRun: false,
227
+ json: false,
228
+ };
229
+ const seen = new Set();
230
+ let epicKey;
231
+ for (let i = 0; i < rest.length; i += 1) {
232
+ const arg = rest[i];
233
+ if (!arg.startsWith("-")) {
234
+ if (epicKey !== undefined) {
235
+ return parseError(`Unexpected argument '${arg}'.`);
236
+ }
237
+ epicKey = arg;
238
+ continue;
239
+ }
240
+ if (!allowed.includes(arg)) {
241
+ return parseError(`Unknown flag '${arg}' for '${verbLabel(verb)}'.`);
242
+ }
243
+ // `--field` is the one repeatable flag; everything else is a singleton.
244
+ if (arg !== "--field") {
245
+ if (seen.has(arg))
246
+ return parseError(`Duplicate flag '${arg}'.`);
247
+ seen.add(arg);
248
+ }
249
+ switch (arg) {
250
+ case "--dry-run":
251
+ options.dryRun = true;
252
+ break;
253
+ case "--json":
254
+ options.json = true;
255
+ break;
256
+ case "--field": {
257
+ const name = rest[i + 1];
258
+ const value = rest[i + 2];
259
+ if (name === undefined || value === undefined) {
260
+ return parseError("--field requires a name and a value.");
261
+ }
262
+ options.fields.push({ name, value });
263
+ i += 2;
264
+ break;
265
+ }
266
+ default: {
267
+ const value = rest[i + 1];
268
+ if (value === undefined)
269
+ return parseError(`${arg} requires a value.`);
270
+ i += 1;
271
+ const assigned = assignFlagValue(options, arg, value);
272
+ if (assigned !== null)
273
+ return parseError(assigned);
274
+ }
275
+ }
276
+ }
277
+ if (epicKey === undefined)
278
+ return parseError(`'${verbLabel(verb)}' requires an <EPIC> key.`);
279
+ if (!CONDUCT_EPIC_KEY_PATTERN.test(epicKey)) {
280
+ return parseError(`Invalid epic key '${epicKey}'. Expected the form PROJ-123.`);
281
+ }
282
+ options.epicKey = epicKey;
283
+ const missing = requiredFlagError(options);
284
+ if (missing !== null)
285
+ return parseError(missing);
286
+ return { status: "ok", options };
287
+ }
288
+ /** Human label for a verb in an error message. */
289
+ function verbLabel(verb) {
290
+ return verb === "checkpoint-set" ? "checkpoint set" : verb;
291
+ }
292
+ /** Apply one value-taking flag, returning an error message or `null`. */
293
+ function assignFlagValue(options, flag, value) {
294
+ switch (flag) {
295
+ case "--tickets": {
296
+ const keys = value
297
+ .split(",")
298
+ .map((k) => k.trim())
299
+ .filter((k) => k.length > 0);
300
+ if (keys.length === 0)
301
+ return "--tickets requires at least one ticket key.";
302
+ for (const key of keys) {
303
+ if (!CONDUCT_EPIC_KEY_PATTERN.test(key)) {
304
+ return `Invalid ticket key '${key}' in --tickets. Expected the form PROJ-123.`;
305
+ }
306
+ }
307
+ if (new Set(keys).size !== keys.length)
308
+ return "--tickets must not repeat a ticket key.";
309
+ options.tickets = keys;
310
+ return null;
311
+ }
312
+ case "--ticket": {
313
+ if (!CONDUCT_EPIC_KEY_PATTERN.test(value)) {
314
+ return `Invalid ticket key '${value}'. Expected the form PROJ-123.`;
315
+ }
316
+ options.ticket = value;
317
+ return null;
318
+ }
319
+ case "--base-branch": {
320
+ const reason = validateBranchName(value);
321
+ if (reason)
322
+ return `Invalid --base-branch value: ${reason}`;
323
+ options.baseBranch = value;
324
+ return null;
325
+ }
326
+ case "--checkpoint-path":
327
+ if (value.trim().length === 0)
328
+ return "--checkpoint-path requires a path.";
329
+ options.checkpointPath = value;
330
+ return null;
331
+ case "--prompt-file":
332
+ if (value.trim().length === 0)
333
+ return "--prompt-file requires a path.";
334
+ options.promptFile = value;
335
+ return null;
336
+ case "--agent":
337
+ if (!CONDUCT_EPIC_AGENTS.includes(value)) {
338
+ return `Unsupported agent '${value}'. Expected one of: ${CONDUCT_EPIC_AGENTS.join(", ")}`;
339
+ }
340
+ options.agent = value;
341
+ return null;
342
+ case "--journal":
343
+ options.journal = value;
344
+ return null;
345
+ default:
346
+ return `Unknown flag '${flag}'.`;
347
+ }
348
+ }
349
+ /** Verb-specific required-flag checks. */
350
+ function requiredFlagError(options) {
351
+ if (options.verb === "init" && options.tickets.length === 0) {
352
+ return "init requires --tickets K1,K2,...";
353
+ }
354
+ if (options.verb === "status" && !options.json) {
355
+ return "status requires --json.";
356
+ }
357
+ if (options.verb === "checkpoint-set") {
358
+ if (options.ticket === undefined)
359
+ return "checkpoint set requires --ticket <KEY>.";
360
+ if (options.fields.length === 0 && options.journal === undefined) {
361
+ return "checkpoint set requires at least one --field or --journal.";
362
+ }
363
+ }
364
+ if (options.verb === "spawn") {
365
+ if (options.ticket === undefined)
366
+ return "spawn requires --ticket <KEY>.";
367
+ if (options.promptFile === undefined)
368
+ return "spawn requires --prompt-file <path>.";
369
+ }
370
+ return null;
371
+ }
372
+ // ---------------------------------------------------------------------------
373
+ // Output discipline
374
+ // ---------------------------------------------------------------------------
375
+ /**
376
+ * Emit one success result.
377
+ *
378
+ * With `--json`, stdout carries EXACTLY one JSON object and every human line is
379
+ * diverted to stderr — including the `announced:` line, so a caller parsing
380
+ * stdout never has to strip narration.
381
+ */
382
+ function emitSuccess(deps, json, payload, humanLines = []) {
383
+ if (json) {
384
+ for (const line of humanLines)
385
+ deps.errorLog(line);
386
+ deps.log(JSON.stringify(payload, null, 2));
387
+ }
388
+ else {
389
+ for (const line of humanLines)
390
+ deps.log(line);
391
+ }
392
+ return 0;
393
+ }
394
+ /**
395
+ * Emit one failure. Reasons go to stderr as concise lines; with `--json`, stdout
396
+ * still carries exactly one object, so a caller can parse a failure the same way
397
+ * it parses a success.
398
+ */
399
+ function emitFailure(deps, json, reasons, payload = {}) {
400
+ for (const reason of reasons)
401
+ deps.errorLog(reason);
402
+ if (json) {
403
+ deps.log(JSON.stringify({ ok: false, ...payload, failures: reasons }, null, 2));
404
+ }
405
+ return 1;
406
+ }
407
+ // ---------------------------------------------------------------------------
408
+ // Shared helpers
409
+ // ---------------------------------------------------------------------------
410
+ /** The epic's own branch name. Fixed by contract; never operator-supplied. */
411
+ function epicBranchFor(epicKey) {
412
+ return `epic/${epicKey}`;
413
+ }
414
+ /** Resolve the checkpoint path from `--checkpoint-path` or the default. */
415
+ function resolveCheckpointPath(deps, repoName, epicKey, override) {
416
+ if (override !== undefined)
417
+ return path.resolve(override);
418
+ return resolveConductEpicCheckpointPath(repoName, epicKey, {
419
+ env: deps.env,
420
+ homedir: deps.homedir,
421
+ });
422
+ }
423
+ /**
424
+ * The lock seams built from the injected deps.
425
+ *
426
+ * Exported so a test can drive concurrent acquisitions through the exact bridge
427
+ * every mutating verb uses — the production default in `lock.ts` is never
428
+ * reached from here, so this bridge is what the mutual-exclusion guarantee
429
+ * actually rests on.
430
+ */
431
+ export function buildConductEpicLockSeams(deps) {
432
+ return {
433
+ readFile: (filePath) => deps.fs.readFile(filePath),
434
+ removeFile: (filePath) => deps.fs.unlink(filePath),
435
+ mkdir: (dirPath, options) => deps.fs.mkdir(dirPath, options),
436
+ isProcessAlive: deps.isProcessAlive,
437
+ // The atomic exclusive-create primitive itself, not an exists-then-write
438
+ // pair: contention must surface as an EEXIST rejection from a single
439
+ // syscall, which is the whole mutual-exclusion signal.
440
+ writeFileExclusive: (filePath, data) => deps.fs.writeFileExclusive(filePath, data, { mode: 0o600 }),
441
+ };
442
+ }
443
+ /** This invocation's lock identity. */
444
+ function lockRequest(deps) {
445
+ return {
446
+ ownerPid: deps.pid,
447
+ host: deps.hostname(),
448
+ acquiredAt: deps.now().toISOString(),
449
+ };
450
+ }
451
+ /**
452
+ * Resolve the repository name for PATH derivation, independent of credentials.
453
+ *
454
+ * Falls back to `"unknown"` only when the identity genuinely cannot be resolved
455
+ * from `BAPI_REPO_NAME` or `.bridge/config` — at which point no checkpoint could
456
+ * have been written under a real name either, so the two agree.
457
+ */
458
+ async function resolveRepoNameForPath(deps) {
459
+ try {
460
+ const result = await deps.resolveRepoName({
461
+ env: deps.env,
462
+ cwd: deps.cwd,
463
+ readFile: (filePath) => deps.fs.readFile(filePath),
464
+ });
465
+ if (result.ok)
466
+ return result.repoName;
467
+ }
468
+ catch {
469
+ /* sanitized: an identity failure degrades to the placeholder below */
470
+ }
471
+ return "unknown";
472
+ }
473
+ /** Resolve Bridge access, or a sanitized reason. Never returns the key. */
474
+ async function resolveAccess(deps) {
475
+ const result = await deps.resolveAccess({
476
+ env: deps.env,
477
+ cwd: deps.cwd,
478
+ homedir: deps.homedir,
479
+ platform: deps.platform,
480
+ readFile: (filePath) => deps.fs.readFile(filePath),
481
+ stat: (filePath) => deps.fs.stat(filePath),
482
+ });
483
+ if (result.ok)
484
+ return { ok: true, access: result.access };
485
+ return { ok: false, error: `Bridge credentials could not be resolved: ${result.error}` };
486
+ }
487
+ /** Run `git` with list args in the repository working directory. */
488
+ function git(deps, args) {
489
+ return Promise.resolve(deps.runCommand("git", args, { cwd: deps.cwd }));
490
+ }
491
+ /** The single trimmed line a `git rev-parse`-style command produced, or null. */
492
+ function firstLine(result) {
493
+ const value = result.stdout.split("\n")[0]?.trim() ?? "";
494
+ return value.length === 0 ? null : value;
495
+ }
496
+ /** The SHA from `git ls-remote --heads origin <ref>` output, or null. */
497
+ function lsRemoteSha(result) {
498
+ const line = firstLine(result);
499
+ if (line === null)
500
+ return null;
501
+ const sha = line.split(/\s+/)[0]?.trim() ?? "";
502
+ return sha.length === 0 ? null : sha;
503
+ }
504
+ function isRecord(value) {
505
+ return typeof value === "object" && value !== null && !Array.isArray(value);
506
+ }
507
+ /** Seconds between two ISO timestamps and `now`, clamped at zero. */
508
+ function elapsedSeconds(from, now) {
509
+ if (from === null)
510
+ return null;
511
+ const start = Date.parse(from);
512
+ if (Number.isNaN(start))
513
+ return null;
514
+ return Math.max(0, Math.floor((now.getTime() - start) / 1000));
515
+ }
516
+ /** The in-flight ticket: the FIRST entry that is not `done`. */
517
+ function inFlightTicket(checkpoint) {
518
+ return checkpoint.tickets.find((ticket) => ticket.status !== "done") ?? null;
519
+ }
520
+ /**
521
+ * Run every independent `init` check and ACCUMULATE the failures.
522
+ *
523
+ * The accumulation is the feature. A first-failure-wins preflight turns a
524
+ * five-problem environment into five invocations, each revealing one problem,
525
+ * and the operator fixes them in the order the code happens to check rather than
526
+ * the order that matters. Checks that genuinely depend on an earlier result
527
+ * (everything needing credentials, everything needing a base branch) are skipped
528
+ * with their own recorded failure rather than crashing.
529
+ *
530
+ * NOTHING here mutates: no push, no repoint, no checkpoint write, no lock, and
531
+ * no supervisor-config PUT. The one write this function used to perform — turning
532
+ * `auto_merge_enabled` on — is returned as {@link ConductEpicInitPreflight.pendingSupervisorConfig}
533
+ * for the caller to execute on the success path, because a preflight that
534
+ * accumulates failures must not have already changed a project-wide default by
535
+ * the time it refuses.
536
+ */
537
+ export async function collectConductEpicInitPreflight(deps, options) {
538
+ const failures = [];
539
+ const announcements = [];
540
+ const epicBranch = epicBranchFor(options.epicKey);
541
+ let pendingSupervisorConfig = null;
542
+ // (1) gh authentication.
543
+ try {
544
+ const gh = await deps.runGh(["auth", "status"], { cwd: deps.cwd });
545
+ if (!gh.ok)
546
+ failures.push("gh is not authenticated. Run `gh auth login`.");
547
+ }
548
+ catch {
549
+ failures.push("gh could not be run. Install the GitHub CLI and run `gh auth login`.");
550
+ }
551
+ // (2) Worktrunk, honoring BAPI_WORKTRUNK_BIN.
552
+ const worktrunk = resolveWorktrunkBinary(deps.platform, deps.env);
553
+ try {
554
+ const probe = await deps.runCommand(worktrunk, ["--version"], { cwd: deps.cwd });
555
+ if (probe.exitCode !== 0) {
556
+ failures.push(`Worktrunk ('${worktrunk}') is not available on PATH.`);
557
+ }
558
+ }
559
+ catch {
560
+ failures.push(`Worktrunk ('${worktrunk}') is not available on PATH.`);
561
+ }
562
+ // (3) Bridge credentials.
563
+ const accessResult = await resolveAccess(deps);
564
+ const access = accessResult.ok ? accessResult.access : null;
565
+ if (!accessResult.ok)
566
+ failures.push(accessResult.error);
567
+ let baseBranch = options.baseBranch ?? null;
568
+ let baseSha = null;
569
+ let epicBranchAlreadyAtBase = false;
570
+ if (access !== null) {
571
+ // (4) auto_merge_enabled, and (5) a non-vacuous required-check set.
572
+ const readiness = await getConductorReadiness(access, deps.fetchImpl);
573
+ if (!readiness.ok) {
574
+ failures.push(`Conductor readiness could not be read: ${readiness.error}`);
575
+ }
576
+ else if (!readiness.value.supervisor.auto_merge_enabled) {
577
+ const current = await getEffectiveSupervisorConfig(access, deps.fetchImpl);
578
+ if (!current.ok) {
579
+ failures.push(`auto_merge_enabled is not set and the effective supervisor config could not be read: ${current.error}`);
580
+ }
581
+ else {
582
+ // Wholesale upsert: copy every field, flip exactly one. Omitting a field
583
+ // would reset it to the request model's default.
584
+ const body = {
585
+ teardown_enabled: current.value.teardown_enabled,
586
+ auto_rereview_enabled: current.value.auto_rereview_enabled,
587
+ max_remediation_attempts: current.value.max_remediation_attempts,
588
+ max_remediation_no_progress_attempts: current.value.max_remediation_no_progress_attempts,
589
+ worker_liveness_window_seconds: current.value.worker_liveness_window_seconds,
590
+ auto_merge_enabled: true,
591
+ merge_approval_required: current.value.merge_approval_required,
592
+ };
593
+ if (options.dryRun) {
594
+ announcements.push(`announced: would enable auto_merge_enabled on the ${access.repoName} project defaults (--dry-run: not sent).`);
595
+ }
596
+ else {
597
+ // Deferred, not sent. The success announcement is added by
598
+ // `runConductEpicInit` only after the PUT actually succeeds, so the
599
+ // `announced:` line never claims a settings change that did not happen.
600
+ pendingSupervisorConfig = body;
601
+ }
602
+ }
603
+ }
604
+ // (5) Required checks. `resolveCiChecks` is called EXACTLY ONCE regardless of
605
+ // the readiness shortcut, because its side effect — warming the
606
+ // `poll-ci-checks` cache — is what makes the first `status` poll answer at
607
+ // all. Skipping it when readiness already looks healthy is the bug that
608
+ // makes tick 1 report `available:false` for no reason.
609
+ const resolved = await resolveCiChecks(access, undefined, deps.fetchImpl);
610
+ const resolvedRequired = requiredCheckNamesFromResolve(resolved.ok ? resolved.value : null);
611
+ if (!readiness.ok) {
612
+ // Already recorded above; nothing further can be concluded.
613
+ }
614
+ else if (readiness.value.supervisor.required_checks_empty) {
615
+ if (!resolved.ok) {
616
+ failures.push(`No required CI checks are configured and resolve-ci-checks failed: ${resolved.error}`);
617
+ }
618
+ else if (resolvedRequired.length === 0) {
619
+ failures.push("No required CI checks are configured and resolve-ci-checks returned none. " +
620
+ "A done gate with an empty required set passes vacuously.");
621
+ }
622
+ }
623
+ // (6) No active server-side epic run for this key.
624
+ const runState = await getEpicRunState(access, options.epicKey, deps.fetchImpl);
625
+ if (runState.ok) {
626
+ const status = runState.value.epic_run?.status;
627
+ if (status !== "done" && status !== "abandoned") {
628
+ failures.push(`A server-side epic run for ${options.epicKey} is ${String(status)}. ` +
629
+ "conduct-epic will not drive an epic the v2 reconciler is already conducting.");
630
+ }
631
+ }
632
+ else if (runState.status !== 404) {
633
+ failures.push(`The epic-run state for ${options.epicKey} could not be read: ${runState.error}`);
634
+ }
635
+ // (10) Index-branch override: absent, or this epic's own (a re-init after a
636
+ // crash). A foreign override is named so the operator knows which epic still
637
+ // holds the repository's index.
638
+ const indexBranch = await getIndexBranch(access, deps.fetchImpl);
639
+ if (!indexBranch.ok) {
640
+ failures.push(`The indexed-branch state could not be read: ${indexBranch.error}`);
641
+ }
642
+ else {
643
+ const override = indexBranch.value.override;
644
+ if (override !== null && override.override_branch !== epicBranch) {
645
+ failures.push(`The repository index is already repointed to '${override.override_branch}' by another epic. ` +
646
+ "Run `conduct-epic finish` for that epic first.");
647
+ }
648
+ else if (override !== null && baseBranch === null) {
649
+ // (8) default base: this epic's own override remembers the real original.
650
+ baseBranch = override.original_base_branch;
651
+ }
652
+ }
653
+ // (8) default base, continued: the configured base branch, then `main`.
654
+ if (baseBranch === null) {
655
+ const configured = await getConfigFieldBaseBranch(access, deps.fetchImpl);
656
+ if (configured.ok && configured.value.base_branch !== null) {
657
+ baseBranch = configured.value.base_branch;
658
+ }
659
+ }
660
+ }
661
+ if (baseBranch === null)
662
+ baseBranch = "main";
663
+ const branchReason = validateBranchName(baseBranch);
664
+ if (branchReason) {
665
+ failures.push(`The resolved base branch is unusable: ${branchReason}`);
666
+ }
667
+ else {
668
+ // (8) The base must exist on origin AFTER a fetch — an epic branch cut from a
669
+ // stale local ref silently starts the epic behind main.
670
+ const fetched = await git(deps, ["fetch", "origin", baseBranch]);
671
+ if (fetched.exitCode !== 0) {
672
+ failures.push(`git fetch origin ${baseBranch} failed.`);
673
+ }
674
+ const tip = await git(deps, ["rev-parse", `refs/remotes/origin/${baseBranch}`]);
675
+ baseSha = tip.exitCode === 0 ? firstLine(tip) : null;
676
+ if (baseSha === null) {
677
+ failures.push(`origin/${baseBranch} does not exist after fetching.`);
678
+ }
679
+ // (9) `epic/<EPIC>` must be absent on origin, or already at the base tip.
680
+ const existing = await git(deps, ["ls-remote", "--heads", "origin", `refs/heads/${epicBranch}`]);
681
+ if (existing.exitCode !== 0) {
682
+ failures.push(`git ls-remote could not read origin/${epicBranch}.`);
683
+ }
684
+ else {
685
+ const existingSha = lsRemoteSha(existing);
686
+ if (existingSha !== null) {
687
+ if (baseSha !== null && existingSha === baseSha) {
688
+ epicBranchAlreadyAtBase = true;
689
+ }
690
+ else {
691
+ failures.push(`origin/${epicBranch} already exists at a commit other than the ${baseBranch} tip. ` +
692
+ "Delete it or finish the previous run before re-initializing.");
693
+ }
694
+ }
695
+ }
696
+ }
697
+ // (7) The lock must be free or recoverably dead. Inspected READ-ONLY here;
698
+ // acquisition happens only after every check passes.
699
+ const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
700
+ const lockState = await inspectConductEpicLock(resolveConductEpicLockPath(checkpointPath), lockRequest(deps), buildConductEpicLockSeams(deps));
701
+ if (lockState.kind === "live-foreign") {
702
+ failures.push(`The epic lock is held by live process ${lockState.owner.owner_pid} on ${lockState.owner.host}.`);
703
+ }
704
+ else if (lockState.kind === "remote-host") {
705
+ failures.push(`The epic lock was taken on host ${lockState.owner.host} and cannot be recovered here.`);
706
+ }
707
+ else if (lockState.kind === "unknown") {
708
+ failures.push(`The epic lock is unusable: ${lockState.reason}`);
709
+ }
710
+ return {
711
+ failures,
712
+ announcements,
713
+ access,
714
+ baseBranch,
715
+ baseSha,
716
+ epicBranchAlreadyAtBase,
717
+ pendingSupervisorConfig,
718
+ };
719
+ }
720
+ /** Extract required check NAMES from a `resolve-ci-checks` soft envelope. */
721
+ function requiredCheckNamesFromResolve(value) {
722
+ if (!isRecord(value))
723
+ return [];
724
+ const detail = value["detail"];
725
+ if (!isRecord(detail))
726
+ return [];
727
+ const checks = detail["checks"];
728
+ if (!Array.isArray(checks))
729
+ return [];
730
+ const names = [];
731
+ for (const check of checks) {
732
+ if (!isRecord(check))
733
+ continue;
734
+ if (check["required"] !== true)
735
+ continue;
736
+ const name = check["name"];
737
+ if (typeof name === "string" && name.trim().length > 0)
738
+ names.push(name.trim());
739
+ }
740
+ return names;
741
+ }
742
+ /**
743
+ * `conduct-epic init` — the only verb that provisions.
744
+ *
745
+ * Refuses a second run outright: an existing checkpoint means an epic is already
746
+ * under way, and re-initializing would discard its ticket statuses and counters.
747
+ * That refusal deliberately applies to a CORRUPT existing checkpoint too — the
748
+ * bytes may be all that is left of a half-finished run, and overwriting them as
749
+ * "repair" is worse than making a human look.
750
+ */
751
+ export async function runConductEpicInit(deps, options) {
752
+ // Credential resolution is the PREFLIGHT's job (check 3). Probing here as well
753
+ // would resolve the credential twice and report a failure the preflight is
754
+ // about to report properly, so `init` reads the access it established instead.
755
+ const repoName = await resolveRepoNameForPath(deps);
756
+ const checkpointPath = resolveCheckpointPath(deps, repoName, options.epicKey, options.checkpointPath);
757
+ const epicBranch = epicBranchFor(options.epicKey);
758
+ const existing = await readConductEpicCheckpoint(checkpointPath, deps.fs);
759
+ if (existing.kind !== "missing") {
760
+ return emitFailure(deps, options.json, [
761
+ `${options.epicKey} is already initialized: a checkpoint exists at ${checkpointPath}. ` +
762
+ "Nothing was changed.",
763
+ ], { epic_key: options.epicKey, checkpoint_path: checkpointPath });
764
+ }
765
+ const preflight = await collectConductEpicInitPreflight(deps, options);
766
+ if (preflight.failures.length > 0) {
767
+ for (const line of preflight.announcements)
768
+ deps.errorLog(line);
769
+ return emitFailure(deps, options.json, preflight.failures, {
770
+ epic_key: options.epicKey,
771
+ checkpoint_path: checkpointPath,
772
+ });
773
+ }
774
+ const access = preflight.access;
775
+ if (access === null || preflight.baseBranch === null || preflight.baseSha === null) {
776
+ // Unreachable: any of these being absent records a failure above. Guarded so
777
+ // a future edit cannot turn a missing precondition into a push.
778
+ return emitFailure(deps, options.json, ["init preflight completed without a usable plan."], {
779
+ epic_key: options.epicKey,
780
+ });
781
+ }
782
+ // Starts as the preflight's own lines (which in `--dry-run` already include the
783
+ // would-enable notice) and grows by at most the one success line below.
784
+ const announcements = [...preflight.announcements];
785
+ // A function, not a value: the deterministic plan-line ordering is fixed, but
786
+ // the trailing announcements are only final once the deferred PUT has settled.
787
+ const describePlan = () => [
788
+ `epic: ${options.epicKey}`,
789
+ `repo: ${access.repoName}`,
790
+ `base: ${preflight.baseBranch} @ ${preflight.baseSha}`,
791
+ `branch: ${epicBranch}${preflight.epicBranchAlreadyAtBase ? " (already at the base tip)" : ""}`,
792
+ `tickets: ${options.tickets.join(", ")}`,
793
+ `checkpoint: ${checkpointPath}`,
794
+ ...announcements,
795
+ ];
796
+ if (options.dryRun) {
797
+ return emitSuccess(deps, options.json, {
798
+ ok: true,
799
+ dry_run: true,
800
+ epic_key: options.epicKey,
801
+ epic_branch: epicBranch,
802
+ base_branch: preflight.baseBranch,
803
+ base_sha: preflight.baseSha,
804
+ tickets: options.tickets,
805
+ checkpoint_path: checkpointPath,
806
+ announcements,
807
+ }, ["Planned (dry run — nothing was pushed, repointed, or written):", ...describePlan()]);
808
+ }
809
+ // The FIRST durable mutation of the whole verb, deliberately placed here: every
810
+ // preflight check has passed, and nothing has been pushed, repointed, written,
811
+ // or locked yet. A failed PUT therefore leaves the environment exactly as `init`
812
+ // found it.
813
+ if (preflight.pendingSupervisorConfig !== null) {
814
+ const put = await putSupervisorConfigDefaults(access, preflight.pendingSupervisorConfig, deps.fetchImpl);
815
+ if (!put.ok) {
816
+ return emitFailure(deps, options.json, [`auto_merge_enabled could not be enabled: ${put.error}`], {
817
+ epic_key: options.epicKey,
818
+ checkpoint_path: checkpointPath,
819
+ });
820
+ }
821
+ announcements.push(`announced: auto_merge_enabled was OFF and has been enabled on the ${access.repoName} project defaults.`);
822
+ }
823
+ // Push the epic branch WITHOUT a local checkout: the remote ref is created
824
+ // directly from the fetched remote-tracking ref, so no local branch, worktree,
825
+ // or checked-out state is touched.
826
+ const pushed = await git(deps, [
827
+ "push",
828
+ "origin",
829
+ `refs/remotes/origin/${preflight.baseBranch}:refs/heads/${epicBranch}`,
830
+ ]);
831
+ if (pushed.exitCode !== 0) {
832
+ return emitFailure(deps, options.json, [`Could not create origin/${epicBranch} from the ${preflight.baseBranch} tip.`], {
833
+ epic_key: options.epicKey,
834
+ });
835
+ }
836
+ const repointed = await repointIndexBranch(access, { branch: epicBranch }, deps.fetchImpl);
837
+ if (!repointed.ok) {
838
+ return emitFailure(deps, options.json, [`The repository index could not be repointed to ${epicBranch}: ${repointed.error}`], {
839
+ epic_key: options.epicKey,
840
+ });
841
+ }
842
+ const request = lockRequest(deps);
843
+ const checkpoint = createInitialConductEpicCheckpoint({
844
+ epicKey: options.epicKey,
845
+ repoName: access.repoName,
846
+ epicBranch,
847
+ baseBranchOriginal: preflight.baseBranch,
848
+ ticketKeys: options.tickets,
849
+ now: deps.now().toISOString(),
850
+ lock: { owner_pid: request.ownerPid, host: request.host, acquired_at: request.acquiredAt },
851
+ });
852
+ const written = await writeConductEpicCheckpointAtomic(checkpointPath, checkpoint, deps.fs, {
853
+ skipChmod: deps.platform === "win32",
854
+ });
855
+ if (!written.ok) {
856
+ return emitFailure(deps, options.json, [written.error], { epic_key: options.epicKey });
857
+ }
858
+ // The lock is taken LAST: a lock without a checkpoint is a state no verb can
859
+ // interpret, whereas a checkpoint without a lock is simply an unlocked epic.
860
+ const lock = await acquireConductEpicLock(resolveConductEpicLockPath(checkpointPath), request, buildConductEpicLockSeams(deps));
861
+ if (!lock.acquired) {
862
+ return emitFailure(deps, options.json, [`The epic lock could not be acquired: ${lock.reason}`], {
863
+ epic_key: options.epicKey,
864
+ checkpoint_path: checkpointPath,
865
+ });
866
+ }
867
+ return emitSuccess(deps, options.json, {
868
+ ok: true,
869
+ epic_key: options.epicKey,
870
+ epic_branch: epicBranch,
871
+ base_branch: preflight.baseBranch,
872
+ base_sha: preflight.baseSha,
873
+ tickets: options.tickets,
874
+ checkpoint_path: checkpointPath,
875
+ lock_path: resolveConductEpicLockPath(checkpointPath),
876
+ index_repointed: true,
877
+ announcements,
878
+ }, ["Initialized:", ...describePlan()]);
879
+ }
880
+ /**
881
+ * `conduct-epic status <EPIC> --json` — the ONLY evidence a tick acts on.
882
+ *
883
+ * Two rules shape everything below:
884
+ *
885
+ * - **Every probe fails open.** `gh` down, CI unreachable, the review endpoint
886
+ * unavailable — each leaves its sub-object `null`, adds a `probe_errors` entry,
887
+ * and the command still exits 0 with a complete object. The loop must be able
888
+ * to see its own checkpoint during a GitHub outage.
889
+ * - **The write allowlist is exactly four fields** (`ci_last_poll`,
890
+ * `last_seen_head`, `last_state_change_at`, and a newly discovered
891
+ * `ticket.branch`) plus the normal `updated_at`. Everything else is
892
+ * observational. In particular the checkpoint stores no "expected head":
893
+ * merge identity always comes from a fresh `pr.head_sha`, and a stored
894
+ * expectation would be a second source of truth that goes stale.
895
+ */
896
+ export async function runConductEpicStatus(deps, options) {
897
+ const accessProbe = await resolveAccess(deps);
898
+ const repoName = await resolveRepoNameForPath(deps);
899
+ const checkpointPath = resolveCheckpointPath(deps, repoName, options.epicKey, options.checkpointPath);
900
+ const read = await readConductEpicCheckpoint(checkpointPath, deps.fs);
901
+ if (read.kind === "missing") {
902
+ // Exactly four keys. BAPI-804 asserts this shape to decide whether to run
903
+ // `init`, so extra narration here would be a contract break.
904
+ return emitSuccess(deps, options.json, {
905
+ ok: true,
906
+ epic_key: options.epicKey,
907
+ checkpoint_path: checkpointPath,
908
+ checkpoint_exists: false,
909
+ });
910
+ }
911
+ if (read.kind !== "ok") {
912
+ return emitFailure(deps, options.json, [read.error], {
913
+ epic_key: options.epicKey,
914
+ checkpoint_path: checkpointPath,
915
+ });
916
+ }
917
+ const checkpoint = read.checkpoint;
918
+ const now = deps.now();
919
+ const probeErrors = [];
920
+ const ticket = inFlightTicket(checkpoint);
921
+ const allDone = ticket === null;
922
+ // --- worktree + branch discovery -----------------------------------------
923
+ let discoveredBranch = ticket?.branch ?? null;
924
+ let worktreePath = null;
925
+ let worktreeExists = false;
926
+ if (ticket !== null) {
927
+ try {
928
+ const listed = await git(deps, ["worktree", "list", "--porcelain"]);
929
+ if (listed.exitCode !== 0) {
930
+ probeErrors.push({ probe: "worktree", reason: "git worktree list failed" });
931
+ }
932
+ else {
933
+ const found = discoverTicketWorktree(parseGitWorktreePorcelain(listed.stdout), ticket.key, ticket.branch);
934
+ if (found !== null) {
935
+ discoveredBranch = found.branch;
936
+ worktreePath = found.path;
937
+ try {
938
+ await deps.fs.stat(found.path);
939
+ worktreeExists = true;
940
+ }
941
+ catch {
942
+ worktreeExists = false;
943
+ }
944
+ }
945
+ }
946
+ }
947
+ catch {
948
+ probeErrors.push({ probe: "worktree", reason: "git worktree list could not be run" });
949
+ }
950
+ }
951
+ // --- git facts ------------------------------------------------------------
952
+ let branchHead = null;
953
+ let workerCommits = 0;
954
+ if (discoveredBranch !== null) {
955
+ try {
956
+ const remote = await git(deps, ["ls-remote", "--heads", "origin", discoveredBranch]);
957
+ if (remote.exitCode !== 0) {
958
+ probeErrors.push({ probe: "branch_head", reason: "git ls-remote failed" });
959
+ }
960
+ else {
961
+ branchHead = lsRemoteSha(remote);
962
+ }
963
+ }
964
+ catch {
965
+ probeErrors.push({ probe: "branch_head", reason: "git ls-remote could not be run" });
966
+ }
967
+ if (branchHead !== null) {
968
+ try {
969
+ await git(deps, ["fetch", "origin"]);
970
+ const counted = await git(deps, [
971
+ "rev-list",
972
+ "--count",
973
+ `origin/${checkpoint.epic_branch}..origin/${discoveredBranch}`,
974
+ ]);
975
+ if (counted.exitCode === 0) {
976
+ const parsedCount = Number.parseInt(firstLine(counted) ?? "", 10);
977
+ workerCommits = Number.isSafeInteger(parsedCount) && parsedCount >= 0 ? parsedCount : 0;
978
+ }
979
+ else {
980
+ probeErrors.push({ probe: "worker_commits", reason: "git rev-list failed" });
981
+ }
982
+ }
983
+ catch {
984
+ probeErrors.push({ probe: "worker_commits", reason: "git rev-list could not be run" });
985
+ }
986
+ }
987
+ }
988
+ // --- PR state -------------------------------------------------------------
989
+ let pr = null;
990
+ if (discoveredBranch !== null) {
991
+ const probe = await discoverConductEpicPrState(discoveredBranch, {
992
+ runGh: deps.runGh,
993
+ cwd: deps.cwd,
994
+ });
995
+ if (probe.kind === "ok")
996
+ pr = probe.pr;
997
+ else if (probe.kind === "error")
998
+ probeErrors.push({ probe: "pr", reason: probe.reason });
999
+ }
1000
+ const mergedExternally = pr?.state === "MERGED" && ticket !== null && ticket.status !== "merged" && ticket.status !== "done";
1001
+ // --- Bridge-backed facts --------------------------------------------------
1002
+ const access = accessProbe.ok ? accessProbe.access : null;
1003
+ if (!accessProbe.ok) {
1004
+ probeErrors.push({ probe: "credentials", reason: accessProbe.error });
1005
+ }
1006
+ let doneGateRequired = null;
1007
+ let reviewOptedIn = false;
1008
+ let reviewSource = null;
1009
+ if (access !== null) {
1010
+ const setup = await getEffectiveSupervisorSetup(access, deps.fetchImpl);
1011
+ if (!setup.ok) {
1012
+ probeErrors.push({ probe: "supervisor_setup", reason: setup.error });
1013
+ }
1014
+ else {
1015
+ const gate = parseDoneGateConfig(setup.value.done_gate_config);
1016
+ for (const condition of gate.conditions) {
1017
+ if (condition.type === "required_ci_checks_green") {
1018
+ doneGateRequired = [...condition.required_checks];
1019
+ }
1020
+ else if (condition.type === "review_state") {
1021
+ reviewOptedIn = true;
1022
+ reviewSource = condition.source;
1023
+ }
1024
+ }
1025
+ }
1026
+ }
1027
+ const ci = access === null || pr?.head_sha == null
1028
+ ? null
1029
+ : await collectCiFacts(deps, access, pr.head_sha, doneGateRequired, checkpoint.ci_last_poll, probeErrors);
1030
+ let review = {
1031
+ opted_in: reviewOptedIn,
1032
+ source: reviewSource,
1033
+ available: null,
1034
+ verdict: null,
1035
+ head_sha: null,
1036
+ };
1037
+ if (access !== null && reviewOptedIn && pr?.number != null) {
1038
+ const status = await getPrReviewStatus(access, pr.number, deps.fetchImpl);
1039
+ if (!status.ok) {
1040
+ probeErrors.push({ probe: "review", reason: status.error });
1041
+ review = { opted_in: true, source: reviewSource, available: null, verdict: null, head_sha: null };
1042
+ }
1043
+ else {
1044
+ review = normalizeReviewStatus(status.value, reviewOptedIn, reviewSource);
1045
+ }
1046
+ }
1047
+ let parse = null;
1048
+ if (access !== null) {
1049
+ const parseStatus = await getParseStatus(access, deps.fetchImpl);
1050
+ if (!parseStatus.ok) {
1051
+ probeErrors.push({ probe: "parse", reason: parseStatus.error });
1052
+ }
1053
+ else {
1054
+ parse = normalizeParseStatus(parseStatus.value);
1055
+ }
1056
+ }
1057
+ // --- lock (inspected, NEVER acquired) ------------------------------------
1058
+ const lockState = await inspectConductEpicLock(resolveConductEpicLockPath(checkpointPath), lockRequest(deps), buildConductEpicLockSeams(deps));
1059
+ const lock = {
1060
+ held_by_me: lockState.kind === "owned",
1061
+ owner_pid: "owner" in lockState ? lockState.owner.owner_pid : null,
1062
+ host: "owner" in lockState ? lockState.owner.host : null,
1063
+ alive: lockState.kind === "owned" || lockState.kind === "live-foreign"
1064
+ ? true
1065
+ : lockState.kind === "dead-local"
1066
+ ? false
1067
+ : null,
1068
+ };
1069
+ // --- the four permitted writes -------------------------------------------
1070
+ let lastSeenHead = ticket?.last_seen_head ?? null;
1071
+ let lastStateChangeAt = ticket?.last_state_change_at ?? null;
1072
+ if (ticket !== null) {
1073
+ const next = { ...checkpoint, tickets: [...checkpoint.tickets] };
1074
+ const index = next.tickets.findIndex((entry) => entry.key === ticket.key);
1075
+ let dirty = false;
1076
+ const updatedTicket = { ...next.tickets[index] };
1077
+ if (updatedTicket.branch === null && discoveredBranch !== null) {
1078
+ updatedTicket.branch = discoveredBranch;
1079
+ dirty = true;
1080
+ }
1081
+ if (branchHead !== null && branchHead !== updatedTicket.last_seen_head) {
1082
+ updatedTicket.last_state_change_at = now.toISOString();
1083
+ updatedTicket.last_seen_head = branchHead;
1084
+ lastSeenHead = branchHead;
1085
+ lastStateChangeAt = updatedTicket.last_state_change_at;
1086
+ dirty = true;
1087
+ }
1088
+ next.tickets[index] = updatedTicket;
1089
+ if (ci?.ci_last_poll) {
1090
+ next.ci_last_poll = ci.ci_last_poll;
1091
+ dirty = true;
1092
+ }
1093
+ if (dirty) {
1094
+ next.updated_at = now.toISOString();
1095
+ const written = await writeConductEpicCheckpointAtomic(checkpointPath, next, deps.fs, {
1096
+ skipChmod: deps.platform === "win32",
1097
+ });
1098
+ if (!written.ok)
1099
+ probeErrors.push({ probe: "checkpoint_write", reason: written.error });
1100
+ }
1101
+ }
1102
+ const payload = {
1103
+ ok: true,
1104
+ epic_key: checkpoint.epic_key,
1105
+ epic_branch: checkpoint.epic_branch,
1106
+ checkpoint_path: checkpointPath,
1107
+ checkpoint_exists: true,
1108
+ all_done: allDone,
1109
+ ticket: ticket === null
1110
+ ? null
1111
+ : {
1112
+ key: ticket.key,
1113
+ status: ticket.status,
1114
+ branch: discoveredBranch,
1115
+ pr_number: ticket.pr_number,
1116
+ spawned_at: ticket.spawned_at,
1117
+ respawns: ticket.respawns,
1118
+ conflict_attempts: ticket.conflict_attempts,
1119
+ counters: { ...ticket.counters },
1120
+ },
1121
+ worktree_path: worktreePath,
1122
+ worktree_exists: worktreeExists,
1123
+ branch_head: branchHead,
1124
+ worker_commits_since_spawn: workerCommits,
1125
+ last_seen_head: lastSeenHead,
1126
+ last_state_change_at: lastStateChangeAt,
1127
+ stale_for_seconds: elapsedSeconds(lastStateChangeAt, now),
1128
+ pr,
1129
+ merged_externally: Boolean(mergedExternally),
1130
+ ci: ci?.ci ?? null,
1131
+ review,
1132
+ parse,
1133
+ deadlines: {
1134
+ soft_seconds: checkpoint.deadlines.soft_seconds,
1135
+ hard_seconds: checkpoint.deadlines.hard_seconds,
1136
+ elapsed_since_spawn_seconds: elapsedSeconds(ticket?.spawned_at ?? null, now),
1137
+ },
1138
+ lock,
1139
+ needs_human: checkpoint.needs_human,
1140
+ probe_errors: probeErrors,
1141
+ };
1142
+ return emitSuccess(deps, options.json, payload);
1143
+ }
1144
+ /**
1145
+ * Poll CI for `headSha`, warming the resolve cache at most once.
1146
+ *
1147
+ * `poll-ci-checks` answers `available: false` with a "call resolve-ci-checks
1148
+ * first" action until the repository's check config has been cached. That is an
1149
+ * INSTRUCTION, not an error: resolve once, poll once more, and proceed. Resolve
1150
+ * is also what supplies the required names when the done gate does not.
1151
+ */
1152
+ async function collectCiFacts(deps, access, headSha, doneGateRequired, previous, probeErrors) {
1153
+ let resolvedValue = null;
1154
+ let resolveCalled = false;
1155
+ const ensureResolved = async () => {
1156
+ if (resolveCalled)
1157
+ return;
1158
+ resolveCalled = true;
1159
+ const resolved = await resolveCiChecks(access, headSha, deps.fetchImpl);
1160
+ if (resolved.ok)
1161
+ resolvedValue = resolved.value;
1162
+ else
1163
+ probeErrors.push({ probe: "ci_resolve", reason: resolved.error });
1164
+ };
1165
+ let polled = await pollCiChecks(access, headSha, deps.fetchImpl);
1166
+ if (!polled.ok) {
1167
+ probeErrors.push({ probe: "ci", reason: polled.error });
1168
+ return null;
1169
+ }
1170
+ if (isRecord(polled.value) && polled.value["available"] === false) {
1171
+ await ensureResolved();
1172
+ polled = await pollCiChecks(access, headSha, deps.fetchImpl);
1173
+ if (!polled.ok) {
1174
+ probeErrors.push({ probe: "ci", reason: polled.error });
1175
+ return null;
1176
+ }
1177
+ // Still unavailable after the one resolve-and-re-poll: CI state is UNKNOWN
1178
+ // (BAPI-814/B3). Falling through would build `{complete: false, checks: []}`,
1179
+ // which is indistinguishable from "polled fine, nothing is green yet" — and
1180
+ // would persist a fingerprint derived from an answer that carried no checks,
1181
+ // so the NEXT poll would compare against it and could report
1182
+ // `stable_across_two_polls`. Return before either happens.
1183
+ if (isRecord(polled.value) && polled.value["available"] === false) {
1184
+ probeErrors.push({
1185
+ probe: "ci",
1186
+ reason: "CI checks are unavailable after resolve-ci-checks and a second poll",
1187
+ });
1188
+ return null;
1189
+ }
1190
+ }
1191
+ const detail = isRecord(polled.value) ? polled.value["detail"] : null;
1192
+ const rawChecks = isRecord(detail) && Array.isArray(detail["checks"]) ? detail["checks"] : [];
1193
+ const checks = rawChecks.filter(isRecord).map((check) => ({
1194
+ name: typeof check["name"] === "string" ? check["name"] : "",
1195
+ status: check["status"] ?? null,
1196
+ conclusion: check["conclusion"] ?? null,
1197
+ required: check["required"] !== false,
1198
+ }));
1199
+ let required = doneGateRequired;
1200
+ if (required === null) {
1201
+ await ensureResolved();
1202
+ required = requiredCheckNamesFromResolve(resolvedValue);
1203
+ }
1204
+ // Sorted so the fingerprint is order-independent: GitHub returns checks in an
1205
+ // arbitrary order, and an order flip must not read as an unstable poll.
1206
+ const requiredSorted = [...new Set(required)].sort();
1207
+ const complete = requiredSorted.length > 0 &&
1208
+ requiredSorted.every((name) => {
1209
+ const raw = rawChecks.filter(isRecord).find((check) => check["name"] === name);
1210
+ return raw !== undefined && raw["complete"] === true && raw["passed"] === true;
1211
+ });
1212
+ const fingerprint = JSON.stringify(checks
1213
+ .filter((check) => requiredSorted.includes(check.name))
1214
+ .map((check) => [check.name, check.status ?? null, check.conclusion ?? null])
1215
+ .sort((a, b) => String(a[0]).localeCompare(String(b[0]))));
1216
+ // Stability is a THREE-part identity: same head, same required set, same
1217
+ // per-check conclusions. A head change resets it, which is the point — a green
1218
+ // result observed twice for an old SHA says nothing about the current one.
1219
+ const stable = previous !== null &&
1220
+ previous.head_sha === headSha &&
1221
+ previous.results_fingerprint === fingerprint &&
1222
+ previous.required.length === requiredSorted.length &&
1223
+ previous.required.every((name, i) => name === requiredSorted[i]);
1224
+ return {
1225
+ ci: {
1226
+ required: requiredSorted,
1227
+ complete,
1228
+ stable_across_two_polls: stable,
1229
+ head_sha: headSha,
1230
+ checks,
1231
+ },
1232
+ ci_last_poll: {
1233
+ head_sha: headSha,
1234
+ required: requiredSorted,
1235
+ results_fingerprint: fingerprint,
1236
+ at: deps.now().toISOString(),
1237
+ },
1238
+ };
1239
+ }
1240
+ /** Normalize the review-status envelope into the contract's five fields. */
1241
+ function normalizeReviewStatus(value, optedIn, source) {
1242
+ const available = isRecord(value) && value["available"] === true;
1243
+ const detail = isRecord(value) && isRecord(value["detail"]) ? value["detail"] : null;
1244
+ let verdict = null;
1245
+ if (detail !== null) {
1246
+ const sticky = detail["sticky_verdict"];
1247
+ const native = detail["review_decision"];
1248
+ if (typeof sticky === "string" && sticky.trim().length > 0) {
1249
+ verdict = normalizeVerdict(sticky);
1250
+ }
1251
+ else if (typeof native === "string" && native.trim().length > 0) {
1252
+ verdict = normalizeVerdict(native);
1253
+ }
1254
+ else {
1255
+ verdict = "unknown";
1256
+ }
1257
+ }
1258
+ return {
1259
+ opted_in: optedIn,
1260
+ source,
1261
+ available: isRecord(value) ? Boolean(value["available"]) : null,
1262
+ // An `available: false` envelope still carries whatever verdict the server
1263
+ // could derive; it is passed through rather than nulled, because the caller
1264
+ // reads `available` to decide how much to trust it.
1265
+ verdict: available || verdict !== null ? verdict : null,
1266
+ head_sha: detail !== null && typeof detail["head_sha"] === "string" ? detail["head_sha"] : null,
1267
+ };
1268
+ }
1269
+ /** Map every known verdict spelling onto the contract's three values. */
1270
+ function normalizeVerdict(raw) {
1271
+ const value = raw.trim().toLowerCase();
1272
+ if (value === "approved")
1273
+ return "approved";
1274
+ if (value === "changes_requested")
1275
+ return "changes_requested";
1276
+ return "unknown";
1277
+ }
1278
+ /**
1279
+ * Normalize `/jira/parse-status` into the `parse` sub-object.
1280
+ *
1281
+ * The two timestamps are what make the post-merge barrier CAUSAL rather than
1282
+ * coincidental (BAPI-814/A2). `parse.status` is repository-level and stays
1283
+ * `succeeded` from any earlier parse, so "succeeded" alone cannot tell the loop
1284
+ * whether THIS ticket's merge has been indexed; a start or finish strictly later
1285
+ * than the journaled request time can.
1286
+ *
1287
+ * Malformed primitives become `null` rather than being coerced. A blank string
1288
+ * or a number where a timestamp belongs is unavailable evidence, and coercing it
1289
+ * to something truthy would let the barrier pass on a value that means nothing.
1290
+ */
1291
+ export function normalizeParseStatus(value) {
1292
+ const known = ["idle", "queued", "in_progress", "succeeded", "failed"];
1293
+ const raw = isRecord(value) ? value["status"] : null;
1294
+ const status = typeof raw === "string" && known.includes(raw) ? raw : null;
1295
+ const optionalText = (key) => {
1296
+ const field = isRecord(value) ? value[key] : null;
1297
+ if (typeof field !== "string")
1298
+ return null;
1299
+ const trimmed = field.trim();
1300
+ return trimmed.length === 0 ? null : trimmed;
1301
+ };
1302
+ return {
1303
+ status,
1304
+ terminal: status === "succeeded" || status === "failed",
1305
+ started_at: optionalText("started_at"),
1306
+ finished_at: optionalText("finished_at"),
1307
+ index_branch_override: optionalText("index_branch_override"),
1308
+ };
1309
+ }
1310
+ // ---------------------------------------------------------------------------
1311
+ // checkpoint set
1312
+ // ---------------------------------------------------------------------------
1313
+ /**
1314
+ * `conduct-epic checkpoint set` — schema-validated ABSOLUTE edits under the lock.
1315
+ *
1316
+ * Absolute, not relative: the caller reads `status`, computes `n + 1`, and writes
1317
+ * the result. A `--increment` flag would need the CLI to re-read between the
1318
+ * caller's read and its write, which is exactly the lost-update race the lock
1319
+ * exists to prevent.
1320
+ */
1321
+ export async function runConductEpicCheckpointSet(deps, options) {
1322
+ // No credential probe here: `checkpoint set` is entirely local — a file read,
1323
+ // a schema-validated edit, and an atomic write. Resolving Bridge access would
1324
+ // make a purely local operation fail-prone during a credential outage.
1325
+ const repoName = await resolveRepoNameForPath(deps);
1326
+ const checkpointPath = resolveCheckpointPath(deps, repoName, options.epicKey, options.checkpointPath);
1327
+ // LOCK FIRST, then read (BAPI-814/B5). Reading before acquiring is a
1328
+ // read-before-lock race: the bytes this call validates and rewrites could be
1329
+ // replaced by another process between the read and the acquisition, and the
1330
+ // write would then silently discard that process's update. Everything that
1331
+ // observes or mutates checkpoint state now happens inside one lock lifetime.
1332
+ const lock = await acquireConductEpicLock(resolveConductEpicLockPath(checkpointPath), lockRequest(deps), buildConductEpicLockSeams(deps));
1333
+ if (!lock.acquired) {
1334
+ return emitFailure(deps, options.json, [`The epic lock could not be acquired: ${lock.reason}`]);
1335
+ }
1336
+ try {
1337
+ const read = await readConductEpicCheckpoint(checkpointPath, deps.fs);
1338
+ if (read.kind === "missing") {
1339
+ return emitFailure(deps, options.json, [
1340
+ `No checkpoint exists at ${checkpointPath}. Run \`conduct-epic init\` first.`,
1341
+ ]);
1342
+ }
1343
+ if (read.kind !== "ok")
1344
+ return emitFailure(deps, options.json, [read.error]);
1345
+ const checkpoint = read.checkpoint;
1346
+ const ticketKey = options.ticket;
1347
+ const index = checkpoint.tickets.findIndex((entry) => entry.key === ticketKey);
1348
+ if (index === -1) {
1349
+ return emitFailure(deps, options.json, [
1350
+ `${ticketKey} is not one of this epic's tickets (${checkpoint.tickets.map((t) => t.key).join(", ")}).`,
1351
+ ]);
1352
+ }
1353
+ const next = {
1354
+ ...checkpoint,
1355
+ counters: { ...checkpoint.counters },
1356
+ tickets: checkpoint.tickets.map((entry) => ({
1357
+ ...entry,
1358
+ counters: { ...entry.counters },
1359
+ journal: [...entry.journal],
1360
+ })),
1361
+ };
1362
+ const now = deps.now().toISOString();
1363
+ const statusBefore = next.tickets[index].status;
1364
+ // Applied in argv order, so a repeated field's LAST assignment wins — the
1365
+ // same rule a shell applies to repeated options.
1366
+ for (const assignment of options.fields) {
1367
+ const applied = applyFieldAssignment(next, index, assignment);
1368
+ if (applied !== null) {
1369
+ return emitFailure(deps, options.json, [applied]);
1370
+ }
1371
+ }
1372
+ if (next.tickets[index].status !== statusBefore) {
1373
+ next.tickets[index].last_state_change_at = now;
1374
+ }
1375
+ if (options.journal !== undefined) {
1376
+ next.tickets[index] = appendTicketJournal(next.tickets[index], options.journal);
1377
+ }
1378
+ next.updated_at = now;
1379
+ const written = await writeConductEpicCheckpointAtomic(checkpointPath, next, deps.fs, {
1380
+ skipChmod: deps.platform === "win32",
1381
+ });
1382
+ if (!written.ok)
1383
+ return emitFailure(deps, options.json, [written.error]);
1384
+ // A concise mutation summary: the FIELD NAMES that changed, never the
1385
+ // checkpoint body, which can carry operator-authored `needs_human` evidence.
1386
+ const changed = options.fields.map((f) => f.name);
1387
+ if (options.journal !== undefined)
1388
+ changed.push("journal");
1389
+ return emitSuccess(deps, options.json, {
1390
+ ok: true,
1391
+ epic_key: checkpoint.epic_key,
1392
+ ticket: ticketKey,
1393
+ updated_fields: changed,
1394
+ checkpoint_path: checkpointPath,
1395
+ }, [`Updated ${ticketKey}: ${changed.join(", ")}`]);
1396
+ }
1397
+ finally {
1398
+ await releaseAcquired(lock);
1399
+ }
1400
+ }
1401
+ /** Release a lock we acquired, never one a successor replaced it with. */
1402
+ async function releaseAcquired(lock) {
1403
+ await lock.release();
1404
+ }
1405
+ /**
1406
+ * Apply one `--field name value` assignment in place, returning an error message
1407
+ * or `null`.
1408
+ *
1409
+ * Every value is parsed by TYPE, not coerced: `"null"` is only null where null is
1410
+ * a legal value, a counter must be a non-negative integer, a branch must survive
1411
+ * `validateBranchName`, and a status must be in the closed vocabulary. A
1412
+ * permissive parser here would let a typo silently write a nonsense state that
1413
+ * the loop then acts on.
1414
+ */
1415
+ function applyFieldAssignment(checkpoint, ticketIndex, assignment) {
1416
+ const { name, value } = assignment;
1417
+ const ticket = checkpoint.tickets[ticketIndex];
1418
+ switch (name) {
1419
+ case "status": {
1420
+ if (!CONDUCT_EPIC_TICKET_STATUSES.includes(value)) {
1421
+ return `Invalid status '${value}'. Expected one of: ${CONDUCT_EPIC_TICKET_STATUSES.join(", ")}`;
1422
+ }
1423
+ ticket.status = value;
1424
+ return null;
1425
+ }
1426
+ case "branch": {
1427
+ if (value === "null") {
1428
+ ticket.branch = null;
1429
+ return null;
1430
+ }
1431
+ const reason = validateBranchName(value);
1432
+ if (reason)
1433
+ return `Invalid branch: ${reason}`;
1434
+ ticket.branch = value;
1435
+ return null;
1436
+ }
1437
+ case "pr_number": {
1438
+ if (value === "null") {
1439
+ ticket.pr_number = null;
1440
+ return null;
1441
+ }
1442
+ const parsed = parseIntegerField(value);
1443
+ if (parsed === null || parsed <= 0)
1444
+ return "pr_number must be a positive integer or null.";
1445
+ ticket.pr_number = parsed;
1446
+ return null;
1447
+ }
1448
+ case "spawned_at": {
1449
+ if (value === "null") {
1450
+ ticket.spawned_at = null;
1451
+ return null;
1452
+ }
1453
+ if (value.trim().length === 0)
1454
+ return "spawned_at must be a non-empty timestamp or null.";
1455
+ ticket.spawned_at = value;
1456
+ return null;
1457
+ }
1458
+ case "respawns":
1459
+ case "conflict_attempts": {
1460
+ const parsed = parseIntegerField(value);
1461
+ if (parsed === null)
1462
+ return `${name} must be a non-negative integer.`;
1463
+ ticket[name] = parsed;
1464
+ return null;
1465
+ }
1466
+ case "counters.sessions_spawned":
1467
+ case "counters.plan_generations_observed":
1468
+ case "counters.merge_attempts": {
1469
+ const parsed = parseIntegerField(value);
1470
+ if (parsed === null)
1471
+ return `${name} must be a non-negative integer.`;
1472
+ const key = name.slice("counters.".length);
1473
+ ticket.counters[key] = parsed;
1474
+ return null;
1475
+ }
1476
+ case "counters.iterations":
1477
+ case "counters.merges": {
1478
+ const parsed = parseIntegerField(value);
1479
+ if (parsed === null)
1480
+ return `${name} must be a non-negative integer.`;
1481
+ checkpoint.counters[name.slice("counters.".length)] = parsed;
1482
+ return null;
1483
+ }
1484
+ case "needs_human": {
1485
+ if (value === "null") {
1486
+ checkpoint.needs_human = null;
1487
+ return null;
1488
+ }
1489
+ let parsed;
1490
+ try {
1491
+ parsed = JSON.parse(value);
1492
+ }
1493
+ catch {
1494
+ return "needs_human must be JSON null or an object with reason, evidence, and at.";
1495
+ }
1496
+ if (parsed === null) {
1497
+ checkpoint.needs_human = null;
1498
+ return null;
1499
+ }
1500
+ if (!isRecord(parsed) ||
1501
+ typeof parsed["reason"] !== "string" ||
1502
+ typeof parsed["evidence"] !== "string" ||
1503
+ typeof parsed["at"] !== "string") {
1504
+ return "needs_human must be JSON null or an object with string reason, evidence, and at.";
1505
+ }
1506
+ checkpoint.needs_human = {
1507
+ reason: parsed["reason"],
1508
+ evidence: parsed["evidence"],
1509
+ at: parsed["at"],
1510
+ };
1511
+ return null;
1512
+ }
1513
+ default:
1514
+ return (`Unknown field '${name}'. Ticket fields: ${TICKET_FIELDS.join(", ")}. ` +
1515
+ `Top-level fields: ${TOP_LEVEL_FIELDS.join(", ")}.`);
1516
+ }
1517
+ }
1518
+ /** Parse a non-negative integer, rejecting `"1.5"`, `"1e3"`, `"0x1"`, and `""`. */
1519
+ function parseIntegerField(value) {
1520
+ if (!/^\d+$/.test(value.trim()))
1521
+ return null;
1522
+ const parsed = Number.parseInt(value.trim(), 10);
1523
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
1524
+ }
1525
+ // ---------------------------------------------------------------------------
1526
+ // spawn
1527
+ // ---------------------------------------------------------------------------
1528
+ /**
1529
+ * `conduct-epic spawn` — open exactly ONE agent tab for a ticket.
1530
+ *
1531
+ * Respawn and conflict budgets are deliberately NOT enforced here. The caller
1532
+ * owns them: it reads `status`, decides whether another attempt is warranted,
1533
+ * and only then calls `spawn`. Enforcing a budget in both places means two
1534
+ * thresholds that drift, and the one in prose wins by accident.
1535
+ */
1536
+ export async function runConductEpicSpawn(deps, options) {
1537
+ // Local-only, like `checkpoint set`: no credential probe. The spawned agent
1538
+ // resolves its own credentials.
1539
+ const repoName = await resolveRepoNameForPath(deps);
1540
+ const checkpointPath = resolveCheckpointPath(deps, repoName, options.epicKey, options.checkpointPath);
1541
+ // LOCK FIRST (BAPI-814/B5). Checkpoint read, ticket validation, worktree
1542
+ // resolution, prompt read, the spawn itself, and the counter write all live
1543
+ // inside one lock lifetime — so no other process can advance the counters
1544
+ // between the read this call validates and the write it performs.
1545
+ const lock = await acquireConductEpicLock(resolveConductEpicLockPath(checkpointPath), lockRequest(deps), buildConductEpicLockSeams(deps));
1546
+ if (!lock.acquired) {
1547
+ return emitFailure(deps, options.json, [`The epic lock could not be acquired: ${lock.reason}`]);
1548
+ }
1549
+ try {
1550
+ const read = await readConductEpicCheckpoint(checkpointPath, deps.fs);
1551
+ if (read.kind === "missing") {
1552
+ return emitFailure(deps, options.json, [
1553
+ `No checkpoint exists at ${checkpointPath}. Run \`conduct-epic init\` first.`,
1554
+ ]);
1555
+ }
1556
+ if (read.kind !== "ok")
1557
+ return emitFailure(deps, options.json, [read.error]);
1558
+ const checkpoint = read.checkpoint;
1559
+ const ticketKey = options.ticket;
1560
+ const index = checkpoint.tickets.findIndex((entry) => entry.key === ticketKey);
1561
+ if (index === -1) {
1562
+ return emitFailure(deps, options.json, [`${ticketKey} is not one of this epic's tickets.`]);
1563
+ }
1564
+ // Resolve the worktree BEFORE reading the prompt and spawning: a tab opened
1565
+ // in the wrong directory does real work in the wrong place.
1566
+ const listed = await git(deps, ["worktree", "list", "--porcelain"]);
1567
+ if (listed.exitCode !== 0) {
1568
+ return emitFailure(deps, options.json, ["git worktree list failed; the ticket worktree could not be resolved."]);
1569
+ }
1570
+ const found = discoverTicketWorktree(parseGitWorktreePorcelain(listed.stdout), ticketKey, checkpoint.tickets[index].branch);
1571
+ if (found === null) {
1572
+ return emitFailure(deps, options.json, [
1573
+ `No worktree was found for ${ticketKey}. Expected a worktree on the ticket's branch, ` +
1574
+ `feature/${ticketKey}, or feature/${ticketKey}-<slug>.`,
1575
+ ]);
1576
+ }
1577
+ try {
1578
+ await deps.fs.stat(found.path);
1579
+ }
1580
+ catch {
1581
+ return emitFailure(deps, options.json, [`The worktree path for ${ticketKey} is not accessible.`]);
1582
+ }
1583
+ let prompt;
1584
+ try {
1585
+ prompt = await deps.fs.readFile(options.promptFile);
1586
+ }
1587
+ catch {
1588
+ return emitFailure(deps, options.json, [`The prompt file '${options.promptFile}' could not be read.`]);
1589
+ }
1590
+ const spawned = await spawnConductEpicAgentTab({
1591
+ ticketKey,
1592
+ worktreePath: found.path,
1593
+ prompt,
1594
+ agent: options.agent,
1595
+ platform: deps.platform,
1596
+ }, deps.spawnTab);
1597
+ if (!spawned.ok)
1598
+ return emitFailure(deps, options.json, [spawned.error]);
1599
+ // Counters advance only AFTER the tab actually opened. Incrementing first
1600
+ // would let a failed spawn consume the caller's respawn budget.
1601
+ const now = deps.now().toISOString();
1602
+ const next = {
1603
+ ...checkpoint,
1604
+ counters: { ...checkpoint.counters },
1605
+ tickets: checkpoint.tickets.map((entry) => ({
1606
+ ...entry,
1607
+ counters: { ...entry.counters },
1608
+ journal: [...entry.journal],
1609
+ })),
1610
+ };
1611
+ next.tickets[index].branch = found.branch;
1612
+ next.tickets[index].counters.sessions_spawned += 1;
1613
+ next.tickets[index] = appendTicketJournal(next.tickets[index], `${now} spawned ${options.agent ?? "claude"} in ${found.branch}`);
1614
+ next.updated_at = now;
1615
+ const written = await writeConductEpicCheckpointAtomic(checkpointPath, next, deps.fs, {
1616
+ skipChmod: deps.platform === "win32",
1617
+ });
1618
+ if (!written.ok)
1619
+ return emitFailure(deps, options.json, [written.error]);
1620
+ return emitSuccess(deps, options.json, {
1621
+ ok: true,
1622
+ epic_key: checkpoint.epic_key,
1623
+ ticket: ticketKey,
1624
+ branch: found.branch,
1625
+ worktree_path: found.path,
1626
+ sessions_spawned: next.tickets[index].counters.sessions_spawned,
1627
+ }, [`Spawned one agent tab for ${ticketKey} in ${found.path}`]);
1628
+ }
1629
+ finally {
1630
+ await releaseAcquired(lock);
1631
+ }
1632
+ }
1633
+ // ---------------------------------------------------------------------------
1634
+ // finish
1635
+ // ---------------------------------------------------------------------------
1636
+ /**
1637
+ * `conduct-epic finish` — restore the server's indexed base branch and wind down.
1638
+ *
1639
+ * Restoration uses `restoreIndexBranch(access)` and NOTHING else. The
1640
+ * checkpoint's `base_branch_original` is display-only: the server holds the
1641
+ * durable override row with the true original, and a local copy that drifted
1642
+ * (because the operator changed the repository's base mid-run) would restore the
1643
+ * index to the wrong branch. `{ ok: true, changed: false }` is a success — that
1644
+ * is what a second `finish` sees.
1645
+ */
1646
+ export async function runConductEpicFinish(deps, options) {
1647
+ const accessProbe = await resolveAccess(deps);
1648
+ if (!accessProbe.ok)
1649
+ return emitFailure(deps, options.json, [accessProbe.error]);
1650
+ const access = accessProbe.access;
1651
+ const checkpointPath = resolveCheckpointPath(deps, await resolveRepoNameForPath(deps), options.epicKey, options.checkpointPath);
1652
+ const read = await readConductEpicCheckpoint(checkpointPath, deps.fs);
1653
+ if (read.kind === "missing") {
1654
+ return emitFailure(deps, options.json, [`No checkpoint exists at ${checkpointPath}.`]);
1655
+ }
1656
+ if (read.kind !== "ok")
1657
+ return emitFailure(deps, options.json, [read.error]);
1658
+ const checkpoint = read.checkpoint;
1659
+ const lock = await acquireConductEpicLock(resolveConductEpicLockPath(checkpointPath), lockRequest(deps), buildConductEpicLockSeams(deps));
1660
+ if (!lock.acquired) {
1661
+ return emitFailure(deps, options.json, [`The epic lock could not be acquired: ${lock.reason}`]);
1662
+ }
1663
+ const restored = await restoreIndexBranch(access, deps.fetchImpl);
1664
+ if (!restored.ok) {
1665
+ await releaseAcquired(lock);
1666
+ return emitFailure(deps, options.json, [`The repository index could not be restored: ${restored.error}`]);
1667
+ }
1668
+ await releaseAcquired(lock);
1669
+ const summary = {
1670
+ ok: true,
1671
+ epic_key: checkpoint.epic_key,
1672
+ epic_branch: checkpoint.epic_branch,
1673
+ index_restored: true,
1674
+ index_changed: restored.value.changed,
1675
+ current_base_branch: restored.value.current_base_branch,
1676
+ counters: { ...checkpoint.counters },
1677
+ needs_human: checkpoint.needs_human,
1678
+ tickets: checkpoint.tickets.map((ticket) => ({
1679
+ key: ticket.key,
1680
+ status: ticket.status,
1681
+ pr_number: ticket.pr_number,
1682
+ counters: { ...ticket.counters },
1683
+ })),
1684
+ };
1685
+ const humanLines = [
1686
+ `Finished ${checkpoint.epic_key} (${checkpoint.epic_branch})`,
1687
+ `index restore: ${restored.value.changed ? "restored" : "already restored"}`,
1688
+ `iterations: ${checkpoint.counters.iterations} merges: ${checkpoint.counters.merges}`,
1689
+ ...checkpoint.tickets.map((ticket) => ` ${ticket.key} ${ticket.status} PR ${ticket.pr_number ?? "-"} ` +
1690
+ `spawned ${ticket.counters.sessions_spawned} plans ${ticket.counters.plan_generations_observed} ` +
1691
+ `merges ${ticket.counters.merge_attempts}`),
1692
+ `needs_human: ${checkpoint.needs_human === null ? "none" : checkpoint.needs_human.reason}`,
1693
+ ];
1694
+ return emitSuccess(deps, options.json, summary, humanLines);
1695
+ }
1696
+ // ---------------------------------------------------------------------------
1697
+ // Entry point
1698
+ // ---------------------------------------------------------------------------
1699
+ /**
1700
+ * Entry point for the packaged `conduct-epic` subcommand.
1701
+ *
1702
+ * Returns a numeric exit code and never throws for user-facing failure: `0` for
1703
+ * help and success, `1` for everything else with a concise stderr reason.
1704
+ */
1705
+ export async function runConductEpicCli(argv, overrides = {}) {
1706
+ const deps = { ...createDefaultConductEpicDeps(), ...overrides };
1707
+ const parsed = parseConductEpicArgs(argv);
1708
+ if (parsed.status === "help") {
1709
+ deps.log(parsed.usage);
1710
+ return 0;
1711
+ }
1712
+ if (parsed.status === "error") {
1713
+ deps.errorLog(parsed.message);
1714
+ // `--json` is parsed alongside the failing token, so a malformed invocation
1715
+ // cannot be trusted to have set it. Failures from the parser are therefore
1716
+ // reported on stderr only, and the exit code carries the outcome.
1717
+ return 1;
1718
+ }
1719
+ const options = parsed.options;
1720
+ switch (options.verb) {
1721
+ case "init":
1722
+ return runConductEpicInit(deps, options);
1723
+ case "status":
1724
+ return runConductEpicStatus(deps, options);
1725
+ case "checkpoint-set":
1726
+ return runConductEpicCheckpointSet(deps, options);
1727
+ case "spawn":
1728
+ return runConductEpicSpawn(deps, options);
1729
+ case "finish":
1730
+ return runConductEpicFinish(deps, options);
1731
+ }
1732
+ }