@bridge_gpt/mcp-server 0.2.41 → 0.2.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +330 -191
  2. package/build/agent-capabilities/cli.js +2 -1
  3. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  4. package/build/agents.generated.js +2 -2
  5. package/build/claude-review-workflow.js +510 -45
  6. package/build/claude-user-config-doctor.js +42 -11
  7. package/build/cli-release.js +2 -1
  8. package/build/commands.generated.js +6 -5
  9. package/build/conduct-epic/bridge-client.js +354 -113
  10. package/build/conduct-epic/checkpoint-store.js +17 -0
  11. package/build/conduct-epic/cli.js +947 -99
  12. package/build/conduct-epic/cut-protocol.js +327 -0
  13. package/build/conduct-epic/spawn.js +14 -2
  14. package/build/conductor/bridge-api-client.js +148 -1
  15. package/build/conductor/cli.js +109 -1
  16. package/build/conductor/doctor.js +101 -16
  17. package/build/conductor/epic-reconcile.js +72 -19
  18. package/build/conductor/epic-runtime.js +15 -3
  19. package/build/conductor/errors.js +47 -0
  20. package/build/conductor/git-hooks.js +205 -11
  21. package/build/conductor/install-doctor.js +230 -1
  22. package/build/conductor/local-merge.js +130 -28
  23. package/build/conductor/recovery-cli.js +313 -0
  24. package/build/conductor/recovery-operations.js +219 -0
  25. package/build/conductor/tools.js +32 -3
  26. package/build/conductor/worker-ledger-cli.js +27 -1
  27. package/build/conductor-bin.js +20 -16
  28. package/build/credentials-cli.js +3 -2
  29. package/build/docs.generated.js +2 -1
  30. package/build/doctor.js +120 -44
  31. package/build/drive-epic.js +375 -0
  32. package/build/executor/cli.js +48 -1
  33. package/build/executor/env.js +21 -0
  34. package/build/executor/http-client.js +71 -3
  35. package/build/executor/index-scope.js +39 -0
  36. package/build/executor/job-errors.js +9 -0
  37. package/build/executor/job-log-registry.js +69 -0
  38. package/build/executor/job-runner.js +198 -29
  39. package/build/executor/live-worker-registry.js +83 -0
  40. package/build/executor/observation.js +259 -6
  41. package/build/executor/platform.js +147 -3
  42. package/build/executor/process.js +58 -14
  43. package/build/executor/runner.js +454 -48
  44. package/build/executor/test-clock.js +3 -2
  45. package/build/executor/worker-finalization.js +233 -56
  46. package/build/executor/worktree.js +8 -1
  47. package/build/index-scope-contract.js +96 -0
  48. package/build/index.js +2277 -270
  49. package/build/init.js +83 -22
  50. package/build/install-bridge-conductor.js +323 -14
  51. package/build/install-bridge.js +225 -47
  52. package/build/install-doctor.js +23 -9
  53. package/build/install-reexec.js +2 -1
  54. package/build/launcher-config-inspection.js +83 -22
  55. package/build/mcp-host-config.js +331 -67
  56. package/build/mcp-host-targets.js +45 -21
  57. package/build/mcp-identity.js +92 -0
  58. package/build/mcp-install-state.js +94 -1
  59. package/build/mcp-invoke.js +2 -1
  60. package/build/mcp-provisioning.js +45 -12
  61. package/build/mcp-registration-doctor.js +35 -13
  62. package/build/mcp-server-invocation.js +4 -2
  63. package/build/merge-pull-request.js +208 -9
  64. package/build/pipelines.generated.js +305 -15
  65. package/build/plane/cli.js +73 -7
  66. package/build/plane/defaults.js +18 -5
  67. package/build/plane/manifest.js +90 -0
  68. package/build/plane/preflight.js +100 -10
  69. package/build/plane/shutdown.js +71 -3
  70. package/build/plane/test-fakes.js +9 -1
  71. package/build/readme.generated.js +1 -1
  72. package/build/regression-check.js +3 -2
  73. package/build/review-tickets.js +8 -7
  74. package/build/run-unit-tests-launcher.js +149 -6
  75. package/build/schedule-run.js +3 -2
  76. package/build/setup-epic.js +531 -82
  77. package/build/sfcc/tool-wrapper.js +15 -0
  78. package/build/start-tickets-prereqs.js +11 -6
  79. package/build/start-tickets.js +91 -85
  80. package/build/update-check.js +3 -2
  81. package/build/upgrade-advice.js +2 -1
  82. package/build/upgrade-cli.js +50 -18
  83. package/build/version.generated.js +2 -1
  84. package/build/worktree-core.js +31 -17
  85. package/docs/CONDUCTOR.md +22 -0
  86. package/docs/install/mcp-tool-integrations.md +19 -3
  87. package/package.json +2 -2
  88. package/pipelines/greenfield-setup.json +286 -0
@@ -0,0 +1,375 @@
1
+ /**
2
+ * `drive-epic` — the one universal conductor entry point (BAPI-887).
3
+ *
4
+ * Bridge has two conductors and a standing rule that they must never operate on
5
+ * the same epic: two transition authorities on one epic wedge it permanently.
6
+ *
7
+ * - **v2 engine** — bootstrapped by `setup-epic`, which creates a durable
8
+ * `epic_run` row, stores a plan DAG, and approves it.
9
+ * - **LLM-conductor pilot** — `/loop 5m /conduct-epic <EPIC>`, which creates no
10
+ * run row and keeps its whole memory in a local checkpoint plus GitHub.
11
+ *
12
+ * Asking a prompt to pick correctly every time is not a control. This subcommand
13
+ * makes the choice STRUCTURAL: it reads server-side conductor readiness and
14
+ * routes to exactly one path, so no prompt names either underlying conductor and
15
+ * no prompt can present both. Every ticket-authoring surface hands off to
16
+ * `drive-epic` and to nothing else.
17
+ *
18
+ * ## The output invariant
19
+ *
20
+ * Every branch — including every error path — emits **at most one** conductor
21
+ * invocation. An escalation carries NO alternative conductor command: "v2 failed,
22
+ * try the pilot instead" is precisely the dual-authority hazard this subcommand
23
+ * exists to remove. {@link assertSingleConductorInvocation} enforces this on the
24
+ * way out rather than trusting each branch to have got it right.
25
+ *
26
+ * ## Three-state routing, fail-closed
27
+ *
28
+ * - readiness green -> delegate to the `setup-epic` (v2) flow
29
+ * - readiness read, not green -> print the pilot instruction
30
+ * - readiness UNKNOWN -> escalate to the operator, print no conductor
31
+ *
32
+ * Unknown is never treated as not-ready, and selection never happens by
33
+ * exception handling. An unreachable, unauthorized, or malformed readiness
34
+ * response means we do not know which authority owns this epic, and guessing is
35
+ * the failure mode.
36
+ *
37
+ * ## Consolidation day
38
+ *
39
+ * Two conductors is a transitional state; one is to be eliminated at the end of
40
+ * the current evaluation period. Consolidation is:
41
+ *
42
+ * 1. set {@link DRIVE_EPIC_PREFERRED_CONDUCTOR} to the surviving conductor;
43
+ * 2. delete the losing branch in {@link renderConductorHandoff}.
44
+ *
45
+ * No prompt, bundled doc, command mirror, or posture test changes as a result —
46
+ * that is the whole reason the duality is confined to this one subcommand and
47
+ * this one constant. Epics already assigned to the eliminated conductor must be
48
+ * drained first; that drain is a conductor-lifecycle concern outside this file.
49
+ *
50
+ * No MCP tool is registered here. The `tools/list` token budget is deliberately
51
+ * ratcheted down, and a tool would be useless in a bare-chat session anyway,
52
+ * where the model has no tool call to make.
53
+ */
54
+ import { resolveConductorBridgeApiAccess, fetchConductorReadiness, safeDiagnosticMessage, } from "./conductor/bridge-api-client.js";
55
+ import { runSetupEpicCli } from "./setup-epic.js";
56
+ // BAPI-806: mcp-identity.ts is the SOLE source of the package-name literal, so
57
+ // the printed invocation interpolates it rather than repeating it.
58
+ import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
59
+ /** Jira epic key shape. Matches `conduct-epic`'s positional rule exactly. */
60
+ const EPIC_KEY_PATTERN = /^[A-Z]+-[0-9]+$/;
61
+ /** The two conductor paths. Derived types below come from this constant. */
62
+ export const DRIVE_EPIC_CONDUCTORS = ["v2", "pilot"];
63
+ /**
64
+ * THE consolidation-day edit point.
65
+ *
66
+ * When readiness is green, v2 is preferred and the pilot is the fallback for
67
+ * projects that cannot run it. This is expressed as one named constant rather
68
+ * than as branching scattered through the routing logic precisely so that
69
+ * eliminating a conductor is a one-line change plus one deleted branch.
70
+ */
71
+ export const DRIVE_EPIC_PREFERRED_CONDUCTOR = "v2";
72
+ /**
73
+ * The v2 readiness predicate, as a list so a failure can name what is missing.
74
+ *
75
+ * These are the facts v2 actually needs to drive an epic: a stored supervisor
76
+ * posture, a reconciler that is ticking, a provisioned and live executor, and
77
+ * complete GitHub App credentials. Each is read from the SERVER's report and
78
+ * never re-derived locally — two implementations of one classification drift,
79
+ * and a CLI that disagreed with the server about whether v2 can run would be
80
+ * worse than no check at all.
81
+ */
82
+ export const V2_READINESS_REQUIREMENTS = [
83
+ {
84
+ id: "supervisor_setup",
85
+ describe: "supervisor setup stored for this repository",
86
+ satisfied: (r) => r.supervisor.setup_present,
87
+ },
88
+ {
89
+ id: "supervisor_config",
90
+ describe: "supervisor configuration stored for this repository",
91
+ satisfied: (r) => r.supervisor.config_present,
92
+ },
93
+ {
94
+ id: "github_credentials",
95
+ describe: "GitHub App credentials that resolve completely",
96
+ satisfied: (r) => r.github.credentials_complete,
97
+ },
98
+ {
99
+ id: "reconciler_live",
100
+ describe: "a reconciler that is ticking and not stale",
101
+ satisfied: (r) => r.reconciler.liveness_readable && !r.reconciler.stale,
102
+ },
103
+ {
104
+ id: "executor_live",
105
+ describe: "an executor provisioned and reporting ready",
106
+ satisfied: (r) => r.executor.liveness_readable && r.executor.ready === true,
107
+ },
108
+ ];
109
+ export function getDriveEpicUsage() {
110
+ return [
111
+ "Usage: mcp-server drive-epic [options] <EPIC>",
112
+ "",
113
+ "Drives one epic with the conductor this project can actually run. Reads",
114
+ "conductor readiness from Bridge API and routes to exactly one path — it",
115
+ "never asks you to choose.",
116
+ "",
117
+ "Arguments:",
118
+ " <EPIC> Jira epic key, matches [A-Z]+-[0-9]+ (e.g. BAPI-885)",
119
+ "",
120
+ "Options:",
121
+ " --plan-file <path> Plan DAG sidecar. When supplied and the v2 path is",
122
+ " selected, drive-epic runs that bootstrap directly",
123
+ " instead of printing the command to run.",
124
+ " --repo <name> Repo name (default: BAPI_REPO_NAME or .bridge/config)",
125
+ " -h, --help Show this help",
126
+ ].join("\n");
127
+ }
128
+ /**
129
+ * Parse argv. Rejects malformed input BEFORE any side effect — no readiness
130
+ * read, no credential resolution, no delegation.
131
+ */
132
+ export function parseDriveEpicArgs(argv) {
133
+ if (argv.includes("-h") || argv.includes("--help")) {
134
+ return { status: "help", usage: getDriveEpicUsage() };
135
+ }
136
+ const positionals = [];
137
+ let planFile;
138
+ let repo;
139
+ for (let i = 0; i < argv.length; i++) {
140
+ const arg = argv[i];
141
+ if (arg === "--plan-file" || arg === "--repo") {
142
+ const value = argv[i + 1];
143
+ if (value === undefined || value.startsWith("-")) {
144
+ return { status: "error", message: `${arg} requires a value.` };
145
+ }
146
+ if (arg === "--plan-file")
147
+ planFile = value;
148
+ else
149
+ repo = value;
150
+ i++;
151
+ continue;
152
+ }
153
+ const eq = arg.match(/^(--plan-file|--repo)=(.*)$/);
154
+ if (eq) {
155
+ const value = eq[2];
156
+ if (value.trim().length === 0) {
157
+ return { status: "error", message: `${eq[1]} requires a value.` };
158
+ }
159
+ if (eq[1] === "--plan-file")
160
+ planFile = value;
161
+ else
162
+ repo = value;
163
+ continue;
164
+ }
165
+ if (arg.startsWith("-")) {
166
+ return { status: "error", message: `Unsupported flag: ${arg}` };
167
+ }
168
+ positionals.push(arg);
169
+ }
170
+ if (positionals.length === 0) {
171
+ return { status: "error", message: "Missing required epic key." };
172
+ }
173
+ if (positionals.length > 1) {
174
+ // Rejected rather than ignored: a second positional means the caller
175
+ // believes something we did not parse, and driving the wrong epic is not
176
+ // recoverable by re-reading the output.
177
+ return {
178
+ status: "error",
179
+ message: `Expected exactly one epic key, got ${positionals.length}: ${positionals.join(", ")}`,
180
+ };
181
+ }
182
+ const epicKey = positionals[0];
183
+ if (!EPIC_KEY_PATTERN.test(epicKey)) {
184
+ return { status: "error", message: `Malformed epic key '${epicKey}'. Expected e.g. BAPI-885.` };
185
+ }
186
+ const options = { epicKey, ...(planFile ? { planFile } : {}), ...(repo ? { repo } : {}) };
187
+ return { status: "ok", options };
188
+ }
189
+ /**
190
+ * Route a readiness report to exactly one conductor.
191
+ *
192
+ * Pure and total: it takes the already-read report and always returns a
193
+ * decision. The unknown case is produced by the caller, which is the only place
194
+ * that can observe a failed read — selection itself never happens by exception
195
+ * handling, and so never needs to report that it could not decide.
196
+ */
197
+ export function selectConductor(readiness) {
198
+ const missing = V2_READINESS_REQUIREMENTS.filter((req) => !req.satisfied(readiness));
199
+ if (missing.length === 0) {
200
+ return {
201
+ kind: "selected",
202
+ conductor: DRIVE_EPIC_PREFERRED_CONDUCTOR,
203
+ reason: "conductor readiness is green",
204
+ };
205
+ }
206
+ return {
207
+ kind: "selected",
208
+ conductor: "pilot",
209
+ reason: `the engine path still needs ${missing.map((m) => m.describe).join("; ")}`,
210
+ };
211
+ }
212
+ /**
213
+ * Render the selected path and only the selected path.
214
+ *
215
+ * Precondition: the caller has already ruled out delegation. The `v2` branch
216
+ * reads as "green, but no plan file was supplied", which is only true once
217
+ * {@link runDriveEpicCli} has declined to delegate — so route first, render
218
+ * second.
219
+ *
220
+ * CONSOLIDATION DAY: delete the branch of the conductor being eliminated and
221
+ * point {@link DRIVE_EPIC_PREFERRED_CONDUCTOR} at the survivor. Nothing outside
222
+ * this file moves.
223
+ */
224
+ export function renderConductorHandoff(selection, epicKey) {
225
+ if (selection.conductor === "v2") {
226
+ const invocation = `npx -y ${MCP_PACKAGE_NAME} drive-epic ${epicKey} --plan-file <path>`;
227
+ return {
228
+ conductor: "v2",
229
+ invocation,
230
+ lines: [
231
+ `${epicKey}: ${selection.reason}, but no plan file was supplied.`,
232
+ "",
233
+ "The engine path needs a plan DAG, and nothing derives one from an epic key:",
234
+ "the dependency edges and per-ticket touched_files it carries do not exist",
235
+ "anywhere else. Produce one, then re-run this command with it.",
236
+ "",
237
+ ` 1. Run /plan-epic ${epicKey} in an interactive session. It writes the`,
238
+ " sidecar to {docs_dir}/epic-plans/{epic_slug}/epic-plan.dag.json.",
239
+ " If the epic's tickets already exist, run emit-conductor-bundle",
240
+ " finalize instead — it resolves the placeholder keys in an existing",
241
+ " sidecar and attaches touched_files.",
242
+ "",
243
+ ` 2. ${invocation}`,
244
+ "",
245
+ `That second command bootstraps the run for you; ${SETUP_EPIC_SUBCOMMAND} is not`,
246
+ "something you invoke directly.",
247
+ ],
248
+ };
249
+ }
250
+ const invocation = `/loop 5m /conduct-epic ${epicKey}`;
251
+ return {
252
+ conductor: "pilot",
253
+ invocation,
254
+ lines: [
255
+ `${epicKey}: ${selection.reason}. Drive it from an interactive session with:`,
256
+ "",
257
+ ` ${invocation}`,
258
+ ],
259
+ };
260
+ }
261
+ /**
262
+ * Tokens that identify a conductor path in user-facing output. Used by the
263
+ * output guard and by its test — one list, so a new spelling cannot be added to
264
+ * the renderer without the guard seeing it.
265
+ */
266
+ export const CONDUCTOR_INVOCATION_TOKENS = ["setup-epic", "conduct-epic"];
267
+ /** The v2 token, named once so prose and the guard cannot drift apart. */
268
+ const SETUP_EPIC_SUBCOMMAND = CONDUCTOR_INVOCATION_TOKENS[0];
269
+ /**
270
+ * Fail closed if a rendered branch names more than one conductor.
271
+ *
272
+ * This is a guard, not decoration: the whole value of a single entry point is
273
+ * that a reader is never handed a choice, and a well-meaning "you could also
274
+ * try..." line added later would silently reintroduce the dual-authority
275
+ * hazard. Checking on the way out costs nothing and cannot be forgotten.
276
+ */
277
+ export function assertSingleConductorInvocation(text) {
278
+ const named = CONDUCTOR_INVOCATION_TOKENS.filter((token) => text.includes(token));
279
+ if (named.length > 1) {
280
+ throw new Error(`drive-epic emitted more than one conductor invocation (${named.join(", ")}). ` +
281
+ "Exactly one path may ever be presented.");
282
+ }
283
+ }
284
+ export function createDefaultDriveEpicDeps() {
285
+ return {
286
+ resolveAccess: async (repo) => {
287
+ const result = await resolveConductorBridgeApiAccess(repo ? { repoName: repo } : {});
288
+ return result.ok ? { ok: true, access: result.access } : { ok: false, error: result.error };
289
+ },
290
+ readReadiness: (access) => fetchConductorReadiness(access, globalThis.fetch),
291
+ runSetupEpic: (argv) => runSetupEpicCli(argv),
292
+ // stdout is safe here: `drive-epic` is dispatched BEFORE MCP server
293
+ // construction, so nothing has claimed stdout for the protocol transport.
294
+ log: (message) => console.log(message),
295
+ errorLog: (message) => console.error(message),
296
+ };
297
+ }
298
+ /**
299
+ * Run the CLI. Returns `0` on a successful handoff and `1` on any validation,
300
+ * readiness, prerequisite, or execution failure. Never throws: the caller
301
+ * dispatches this at module top level, where an escaping exception would take
302
+ * down the process before any diagnostic reached the operator.
303
+ */
304
+ export async function runDriveEpicCli(argv, overrides = {}) {
305
+ const deps = { ...createDefaultDriveEpicDeps(), ...overrides };
306
+ const parsed = parseDriveEpicArgs(argv);
307
+ if (parsed.status === "help") {
308
+ deps.log(parsed.usage);
309
+ return 0;
310
+ }
311
+ if (parsed.status === "error") {
312
+ deps.errorLog(parsed.message);
313
+ deps.errorLog("");
314
+ deps.errorLog(getDriveEpicUsage());
315
+ return 1;
316
+ }
317
+ const { epicKey, planFile, repo } = parsed.options;
318
+ try {
319
+ // --- Resolve access. A missing credential is UNKNOWN, not not-ready. -----
320
+ const accessResult = await deps.resolveAccess(repo);
321
+ if (!accessResult.ok) {
322
+ return escalate(deps, epicKey, `conductor readiness could not be read: ${accessResult.error}`);
323
+ }
324
+ // --- Read readiness. Any failure here is UNKNOWN. ------------------------
325
+ let readiness;
326
+ try {
327
+ readiness = await deps.readReadiness(accessResult.access);
328
+ }
329
+ catch (err) {
330
+ return escalate(deps, epicKey, `conductor readiness could not be read: ${safeDiagnosticMessage(err, "readiness request failed")}`);
331
+ }
332
+ // --- Route. Exactly one path from here on. -------------------------------
333
+ const selection = selectConductor(readiness);
334
+ // Decide delegation BEFORE rendering. The v2 branch's text is specifically
335
+ // about a missing plan file, and rendering it on the path that has one
336
+ // would produce a message contradicting what is about to happen.
337
+ if (selection.conductor === "v2" && planFile) {
338
+ // Delegate. `setup-epic` owns branch, run-row, plan, and approval
339
+ // behavior; drive-epic reimplements none of it and prints nothing of its
340
+ // own alongside it, so its output remains the only conductor output.
341
+ const setupArgv = ["--epic-key", epicKey, "--plan-file", planFile, ...(repo ? ["--repo", repo] : [])];
342
+ return await deps.runSetupEpic(setupArgv);
343
+ }
344
+ const handoff = renderConductorHandoff(selection, epicKey);
345
+ const text = handoff.lines.join("\n");
346
+ assertSingleConductorInvocation(text);
347
+ deps.log(text);
348
+ return 0;
349
+ }
350
+ catch (err) {
351
+ // Last resort. Sanitized, on stderr, converted to an exit code — a
352
+ // credential must never reach this line, and a stringified unknown is
353
+ // exactly how one escapes.
354
+ deps.errorLog(`drive-epic failed: ${safeDiagnosticMessage(err, "unexpected error")}`);
355
+ return 1;
356
+ }
357
+ }
358
+ /**
359
+ * The escalation branch. Prints NO conductor invocation at all — naming a
360
+ * fallback here is the dual-authority hazard, and an operator who does not know
361
+ * which authority owns an epic must not be nudged toward either one.
362
+ */
363
+ function escalate(deps, epicKey, reason) {
364
+ const lines = [
365
+ `Cannot determine which conductor owns ${epicKey}: ${reason}`,
366
+ "",
367
+ "This is not a not-ready result — it is an unknown one, and starting the wrong",
368
+ "conductor on an epic wedges it permanently. Restore Bridge API access and run",
369
+ "drive-epic again, or ask an operator to resolve conductor readiness.",
370
+ ];
371
+ const text = lines.join("\n");
372
+ assertSingleConductorInvocation(text);
373
+ deps.errorLog(text);
374
+ return 1;
375
+ }
@@ -14,6 +14,7 @@ import { DEFAULT_EXECUTOR_AGENT_ID } from "./agent-identity.js";
14
14
  import { createDefaultExecutorDeps } from "./deps.js";
15
15
  import { resolveBaseUrl, resolveExecutorApiAccess, EXECUTOR_BASE_URL_REQUIRED_MESSAGE, } from "./credentials.js";
16
16
  import { createExecutorHttpClient } from "./http-client.js";
17
+ import { startExecutorSleepAssertion } from "./platform.js";
17
18
  import { runExecutor } from "./runner.js";
18
19
  import { runExecutorWatchCli } from "./watch-cli.js";
19
20
  /** Fixed executor timing/behavior defaults. */
@@ -210,6 +211,21 @@ export function parseExecutorArgs(argv, context) {
210
211
  };
211
212
  return { kind: "ok", options };
212
213
  }
214
+ /**
215
+ * Default signal registrar: `process.on`, disposed with `process.off`.
216
+ *
217
+ * The SAME register-and-return-an-unsubscriber shape the notifications CLI and
218
+ * the plane supervisor already use. Defined here rather than inside `runExecutor`
219
+ * so the runner never touches `process` itself — the runner stays a pure loop
220
+ * over injected boundaries, and this bootstrap file remains the one place that
221
+ * knows the process object exists.
222
+ */
223
+ function defaultSignalRegistrar(signal, handler) {
224
+ process.on(signal, handler);
225
+ return () => {
226
+ process.off(signal, handler);
227
+ };
228
+ }
213
229
  function hasRepoFlag(argv) {
214
230
  return argv.some((a) => a === "--repo" || a === "--repos" || a.startsWith("--repos="));
215
231
  }
@@ -306,12 +322,43 @@ export async function runExecutorCli(argv, overrides = {}) {
306
322
  // Hand the loop the VALIDATED runtime configuration: the parsed options plus the
307
323
  // normalized base URL resolution actually settled on.
308
324
  const runtimeOptions = { ...options, resolvedBaseUrl };
325
+ // --- Sleep assertion (BAPI-828) ---------------------------------------
326
+ // Started HERE — after argument parsing, base-URL resolution, credential
327
+ // resolution, and HTTP client construction have all succeeded, and immediately
328
+ // before the loop begins. That position is the whole design: it guarantees the
329
+ // assertion exists before the first claim can spawn a worker, while a `--help`
330
+ // run, an unknown flag, a missing base URL, and a credential failure each
331
+ // return above without ever spawning `caffeinate`. Fail-open throughout — a
332
+ // host that cannot hold the assertion still claims and still works.
333
+ const sleepAssertion = startExecutorSleepAssertion({
334
+ platform: context.platform,
335
+ // The EXECUTOR's environment, read straight from deps. A worker environment
336
+ // never reaches this call, and the opt-out is never forwarded to one.
337
+ env: deps.env,
338
+ cwd: deps.cwd,
339
+ executorPid: context.pid,
340
+ spawnProcess: deps.spawnProcess,
341
+ setTimer: deps.setTimer,
342
+ clearTimer: deps.clearTimer,
343
+ errorLog,
344
+ });
345
+ const runnerSeams = {
346
+ onSignal: overrides.onSignal ?? defaultSignalRegistrar,
347
+ };
309
348
  try {
310
- return await run(runtimeOptions, deps, httpClient);
349
+ return await run(runtimeOptions, deps, httpClient, runnerSeams);
311
350
  }
312
351
  catch (err) {
313
352
  const message = err instanceof Error ? err.message : String(err);
314
353
  errorLog(`Error: executor exited unexpectedly: ${message.slice(0, 200)}`);
315
354
  return 1;
316
355
  }
356
+ finally {
357
+ // Released for a normal completion, a graceful signal shutdown, AND a thrown
358
+ // runner error alike. Without the `finally` the throw path above would return
359
+ // its exit code while still holding a `caffeinate` child — which, being a
360
+ // live child of this process, is exactly the kind of thing that keeps a Node
361
+ // process from exiting when nobody calls `process.exit`.
362
+ await sleepAssertion.release();
363
+ }
317
364
  }
@@ -40,6 +40,7 @@
40
40
  * (`Read(~/.config/bridge/**)` in `permissions.ts`).
41
41
  */
42
42
  import { PR_BASE_BRANCH_ENV_VAR } from "../pr-base-contract.js";
43
+ import { INDEX_SCOPE_ENV_VAR, validateOptionalIndexScope } from "../index-scope-contract.js";
43
44
  /** Non-secret operational keys forwarded to the worker when present. */
44
45
  const ALLOWED_ENV_KEYS = [
45
46
  "PATH",
@@ -92,6 +93,14 @@ export const EXPLICIT_DENY_KEYS = [
92
93
  "GH_TOKEN",
93
94
  "CLAUDE_CONFIG_DIR",
94
95
  "XDG_CONFIG_HOME",
96
+ // BAPI-844: the index-scope declaration follows the BAPI_BASE_BRANCH rule —
97
+ // absent from ALLOWED_ENV_KEYS so it is never copied from `parentEnv`, and
98
+ // denied EXPLICITLY here so an operator's ambient `BAPI_INDEX_SCOPE` can never
99
+ // reach a worker. A worker's research index is a run-scoped decision; a value
100
+ // exported in the shell that happened to launch the executor is not that
101
+ // decision, and inheriting one would route an unscoped worker's plan and review
102
+ // at another epic's shadow index while nothing in the job said so.
103
+ INDEX_SCOPE_ENV_VAR,
95
104
  ];
96
105
  /**
97
106
  * True only when `key` is a safe, allowlisted operational key. Exported so the
@@ -130,10 +139,22 @@ export function buildExecutorBaseWorkerEnv(parentEnv, options = {}) {
130
139
  }
131
140
  }
132
141
  env.BRIDGE_SKIP_PREPUSH = "1";
142
+ env.MAX_MCP_OUTPUT_TOKENS = "50000";
133
143
  if (typeof options.effectiveBaseBranch === "string" &&
134
144
  options.effectiveBaseBranch.length > 0) {
135
145
  env[PR_BASE_BRANCH_ENV_VAR] = options.effectiveBaseBranch;
136
146
  }
147
+ // BAPI-844: the index-scope declaration, set ONLY from the explicit option and
148
+ // only after the shared shape check. A malformed explicit value throws here —
149
+ // before an environment exists — rather than being dropped: silently omitting it
150
+ // would spawn a worker that researches the canonical index while the job it came
151
+ // from declared a scope, which is the one outcome the declaration exists to
152
+ // prevent. An absent option adds no key at all, so the returned object is
153
+ // unchanged for every unscoped worker.
154
+ const indexScope = validateOptionalIndexScope(options.indexScope);
155
+ if (indexScope !== undefined) {
156
+ env[INDEX_SCOPE_ENV_VAR] = indexScope;
157
+ }
137
158
  // Nothing agent-specific is added here, and deliberately nothing secret. An
138
159
  // adapter that needs to forward an operator-owned credential declares it as a
139
160
  // passthrough and copies it onto the object this function returns; see
@@ -34,6 +34,45 @@ function parseStopRequested(bodyText) {
34
34
  return false;
35
35
  }
36
36
  }
37
+ /**
38
+ * The claim response header carrying server-derived dispatcher liveness
39
+ * (BAPI-871). Exported so the client and its tests name it once.
40
+ */
41
+ export const RECONCILER_LIVENESS_HEADER = "X-BAPI-Reconciler-Liveness";
42
+ /** The only values the server is documented to send. */
43
+ const RECONCILER_LIVENESS_VALUES = [
44
+ "fresh",
45
+ "stale",
46
+ "never_seen",
47
+ "unknown",
48
+ ];
49
+ /**
50
+ * Read the liveness header off a claim response, admitting ONLY documented
51
+ * values.
52
+ *
53
+ * Absent, blank, or unrecognized all map to `unknown` — the compatibility path.
54
+ * An executor talking to a backend from before this header existed must keep
55
+ * claiming exactly as it did, and "I have no verdict" is what `unknown` means.
56
+ * A value the server never promised is treated the same way rather than being
57
+ * passed through: an unrecognized string reaching the runner's availability
58
+ * logic could silently become a fifth state nobody handles.
59
+ *
60
+ * Header lookup is case-insensitive via `Headers.get`, which is required by the
61
+ * spec — HTTP header names are not case-sensitive, and proxies do re-case them.
62
+ */
63
+ export function parseReconcilerLivenessHeader(headers) {
64
+ let raw = null;
65
+ try {
66
+ raw = headers.get(RECONCILER_LIVENESS_HEADER);
67
+ }
68
+ catch {
69
+ return "unknown";
70
+ }
71
+ const value = (raw ?? "").trim().toLowerCase();
72
+ return RECONCILER_LIVENESS_VALUES.includes(value)
73
+ ? value
74
+ : "unknown";
75
+ }
37
76
  /**
38
77
  * Map a mutation (heartbeat/complete/fail) HTTP response onto the fencing
39
78
  * outcome. `allowInvalidResult` maps 422 to `invalid_job_result` (complete only).
@@ -68,7 +107,9 @@ export function createExecutorHttpClient(config) {
68
107
  body: JSON.stringify(body),
69
108
  });
70
109
  const text = await res.text();
71
- return { status: res.status, text };
110
+ // `headers` is surfaced (optional, so the many response fakes that omit it
111
+ // stay assignable) purely for the claim path's liveness annotation.
112
+ return { status: res.status, text, headers: res.headers };
72
113
  }
73
114
  async function mutate(pathSuffix, body, allowInvalidResult, repoName) {
74
115
  try {
@@ -85,24 +126,33 @@ export function createExecutorHttpClient(config) {
85
126
  async claim(manifest) {
86
127
  let status;
87
128
  let text;
129
+ let reconcilerLiveness = "unknown";
88
130
  try {
89
131
  const res = await post("/claim", manifest, manifest.repo_name);
90
132
  status = res.status;
91
133
  text = res.text;
134
+ // Parsed on EVERY response, then attached only to the successful
135
+ // variants below. A response fake that omits `headers` degrades to
136
+ // `unknown` rather than throwing.
137
+ if (res.headers)
138
+ reconcilerLiveness = parseReconcilerLivenessHeader(res.headers);
92
139
  }
93
140
  catch (err) {
94
141
  const message = err instanceof Error ? err.message : String(err);
95
142
  return { kind: "retryable", error: `claim network error: ${boundedDetail(message)}` };
96
143
  }
144
+ // Both successful outcomes carry the verdict. Nothing about the liveness
145
+ // value changes the classification below: a 204 is still "no job", and the
146
+ // retryable/fatal split is untouched.
97
147
  if (status === 204)
98
- return { kind: "none" };
148
+ return { kind: "none", reconcilerLiveness };
99
149
  if (status === 200) {
100
150
  try {
101
151
  const job = JSON.parse(text);
102
152
  if (!job || typeof job !== "object" || typeof job.claim_token !== "string") {
103
153
  return { kind: "fatal", error: "claim returned a malformed job payload" };
104
154
  }
105
- return { kind: "claimed", job };
155
+ return { kind: "claimed", job, reconcilerLiveness };
106
156
  }
107
157
  catch {
108
158
  return { kind: "fatal", error: "claim returned invalid JSON" };
@@ -142,6 +192,24 @@ export function createExecutorHttpClient(config) {
142
192
  result.stop_requested = true;
143
193
  return result;
144
194
  },
195
+ /**
196
+ * Publish per-process liveness (BAPI-871).
197
+ *
198
+ * Authenticated with the manifest's PRIMARY repo key. The server verifies
199
+ * every entry in `repo_names` before writing anything, so a key that does not
200
+ * hold one of them gets a 4xx — reported here as `failed` and nothing more.
201
+ * Delivery is non-critical monitoring: it never throws, never classifies a
202
+ * fencing outcome, and never stops the caller from claiming or running work.
203
+ */
204
+ async processHeartbeat(request) {
205
+ try {
206
+ const { status, text } = await post("/process-heartbeat", request, request.repo_names[0] ?? "");
207
+ return status >= 200 && status < 300 && isOkBody(text) ? "delivered" : "failed";
208
+ }
209
+ catch {
210
+ return "failed";
211
+ }
212
+ },
145
213
  complete(job, completion) {
146
214
  return mutate(`/${job.id}/complete`, {
147
215
  repo_name: job.repo_name,
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Per-job index-scope resolution (BAPI-844).
3
+ *
4
+ * The server names the index scope a v2 job's worker must research against, as
5
+ * the top-level `index_scope_id` on the claimed job — the same wire position as
6
+ * `epic_run_id`, and for the same reason: it is a server-minted IDENTITY, not
7
+ * job-type-specific payload data. There is exactly one wire representation, and
8
+ * this module is the only place the executor reads it.
9
+ *
10
+ * Mirrors {@link ../executor/base-branch.ts} deliberately, including its posture:
11
+ *
12
+ * - ABSENT → an unscoped job. The worker's environment, command, and routing are
13
+ * byte-identical to the pre-BAPI-844 ones.
14
+ * - PRESENT and well-formed → carried into the worker environment as an explicit
15
+ * spawn option (never inherited).
16
+ * - PRESENT but malformed → a contract failure, resolved BEFORE any side effect.
17
+ * Dropping it instead would spawn a worker that silently researched the
18
+ * canonical index while the job it came from declared a scope.
19
+ */
20
+ import { validateOptionalIndexScope } from "../index-scope-contract.js";
21
+ /**
22
+ * Resolve the declared index scope for one executor job.
23
+ *
24
+ * The diagnostic names the contract only. The submitted value never appears in
25
+ * it, because this string reaches `/fail`, the job row, and operator output —
26
+ * all places an opaque routing token must not be persisted.
27
+ */
28
+ export function resolveExecutorJobIndexScope(job) {
29
+ try {
30
+ const indexScope = validateOptionalIndexScope(job.index_scope_id);
31
+ return indexScope === undefined ? { ok: true } : { ok: true, indexScope };
32
+ }
33
+ catch {
34
+ return {
35
+ ok: false,
36
+ error: "job index_scope_id is present but is not a valid index-scope identity.",
37
+ };
38
+ }
39
+ }
@@ -20,6 +20,14 @@
20
20
  * opened a PR whose base branch is not the run base, so `claude-review.yml`
21
21
  * (triggered only on `branches: [main]`) never fires and the `code_review`
22
22
  * gate would hang forever; see `worker-finalization.ts`.
23
+ * - `WorkerFinalizationPrNotAttached` — an implementation-style job (BAPI-862)
24
+ * exited cleanly and its exact HEAD is durable on origin, but no usable pull
25
+ * request exists for that branch: none is visible, its URL is blank, its
26
+ * description is blank (BAPI-824 folded in), or it does not yet carry the
27
+ * pushed head. The work is COMPLETE and PUSHED — only the attachment is
28
+ * missing — so this kind is deliberately distinct from every other
29
+ * finalization failure, and its guidance names attachment and warns against
30
+ * deleting or rebasing the branch; see `worker-finalization.ts`.
23
31
  * - `WorkerFinalizationSavedButUnfinalized` — an implementation-style job
24
32
  * (BAPI-762) exited cleanly, and an origin branch exists at an authoritative
25
33
  * tip that does NOT match the worker's own HEAD — durable work exists on
@@ -98,6 +106,7 @@ export const WorktreeLostBeforePush = "WorktreeLostBeforePush";
98
106
  export const BranchMismatch = "BranchMismatch";
99
107
  export const WorkerFinalizationMissingRemoteBranchAndPr = "WorkerFinalizationMissingRemoteBranchAndPr";
100
108
  export const WorkerFinalizationPrBaseMismatch = "WorkerFinalizationPrBaseMismatch";
109
+ export const WorkerFinalizationPrNotAttached = "WorkerFinalizationPrNotAttached";
101
110
  export const WorkerFinalizationSavedButUnfinalized = "WorkerFinalizationSavedButUnfinalized";
102
111
  export const WorkerConfigIsolation = "ContractError.WorkerConfigIsolation";
103
112
  export const PreSpawnVerification = "ContractError.PreSpawnVerification";