@bridge_gpt/mcp-server 0.2.39 → 0.2.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +10 -10
  2. package/build/agent-capabilities/cli.js +2 -1
  3. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  4. package/build/claude-user-config-doctor.js +42 -11
  5. package/build/cli-release.js +2 -1
  6. package/build/commands.generated.js +4 -4
  7. package/build/conduct-epic/bridge-client.js +354 -113
  8. package/build/conduct-epic/checkpoint-store.js +75 -2
  9. package/build/conduct-epic/cli.js +795 -109
  10. package/build/conduct-epic/cut-protocol.js +327 -0
  11. package/build/conduct-epic/pr-state.js +113 -24
  12. package/build/conduct-epic/spawn.js +14 -2
  13. package/build/conductor/bridge-api-client.js +27 -1
  14. package/build/conductor/cli.js +46 -1
  15. package/build/conductor/doctor.js +101 -16
  16. package/build/conductor/epic-reconcile.js +72 -19
  17. package/build/conductor/epic-runtime.js +15 -3
  18. package/build/conductor/errors.js +47 -0
  19. package/build/conductor/git-hooks.js +205 -11
  20. package/build/conductor/install-doctor.js +230 -1
  21. package/build/conductor/local-merge.js +130 -28
  22. package/build/conductor/tools.js +32 -3
  23. package/build/conductor/worker-ledger-cli.js +27 -1
  24. package/build/conductor-bin.js +15 -15
  25. package/build/credentials-cli.js +3 -2
  26. package/build/doctor.js +107 -41
  27. package/build/executor/cli.js +48 -1
  28. package/build/executor/env.js +21 -0
  29. package/build/executor/index-scope.js +39 -0
  30. package/build/executor/job-log-registry.js +69 -0
  31. package/build/executor/job-runner.js +148 -26
  32. package/build/executor/live-worker-registry.js +83 -0
  33. package/build/executor/observation.js +167 -6
  34. package/build/executor/platform.js +147 -3
  35. package/build/executor/process.js +58 -14
  36. package/build/executor/runner.js +235 -48
  37. package/build/executor/test-clock.js +3 -2
  38. package/build/index-scope-contract.js +96 -0
  39. package/build/index.js +153 -204
  40. package/build/init.js +83 -22
  41. package/build/install-bridge-conductor.js +323 -14
  42. package/build/install-bridge.js +202 -38
  43. package/build/install-doctor.js +23 -9
  44. package/build/install-reexec.js +2 -1
  45. package/build/launcher-config-inspection.js +83 -22
  46. package/build/mcp-host-config.js +331 -67
  47. package/build/mcp-host-targets.js +45 -21
  48. package/build/mcp-identity.js +92 -0
  49. package/build/mcp-install-state.js +94 -1
  50. package/build/mcp-invoke.js +2 -1
  51. package/build/mcp-provisioning.js +45 -12
  52. package/build/mcp-registration-doctor.js +35 -13
  53. package/build/mcp-server-invocation.js +4 -2
  54. package/build/merge-pull-request.js +208 -9
  55. package/build/pipelines.generated.js +3 -3
  56. package/build/plane/defaults.js +4 -1
  57. package/build/plane/preflight.js +81 -10
  58. package/build/plane/test-fakes.js +9 -1
  59. package/build/readme.generated.js +1 -1
  60. package/build/regression-check.js +3 -2
  61. package/build/review-tickets.js +8 -7
  62. package/build/run-unit-tests-launcher.js +74 -1
  63. package/build/schedule-run.js +3 -2
  64. package/build/setup-epic.js +453 -78
  65. package/build/sfcc/tool-wrapper.js +15 -0
  66. package/build/start-tickets-prereqs.js +11 -6
  67. package/build/start-tickets.js +91 -85
  68. package/build/update-check.js +3 -2
  69. package/build/upgrade-advice.js +2 -1
  70. package/build/upgrade-cli.js +50 -18
  71. package/build/version.generated.js +1 -1
  72. package/docs/CONDUCTOR.md +22 -0
  73. package/docs/install/mcp-tool-integrations.md +19 -3
  74. package/package.json +2 -2
@@ -25,6 +25,7 @@ import { readFile, mkdir, writeFile, rename, chmod, unlink, open } from "fs/prom
25
25
  import os from "os";
26
26
  import readline from "readline";
27
27
  import { migrateAgentConfigCredentialToStore, } from "./agent-config-credential-migration.js";
28
+ import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
28
29
  /** The only agent-config sources the migration knows how to scan. */
29
30
  const ALLOWED_SOURCES = [".mcp.json", ".cursor/mcp.json"];
30
31
  /** Every subcommand this CLI accepts, in help order. */
@@ -33,7 +34,7 @@ const SUBCOMMANDS = ["migrate-agent-config"];
33
34
  export function getCredentialsUsage() {
34
35
  return [
35
36
  "Usage:",
36
- " npx -y @bridge_gpt/mcp-server credentials migrate-agent-config \\",
37
+ ` npx -y ${MCP_PACKAGE_NAME} credentials migrate-agent-config \\`,
37
38
  " [--write-credentials|--no-write-credentials] \\",
38
39
  " [--source=.mcp.json|--source=.cursor/mcp.json]",
39
40
  "",
@@ -222,7 +223,7 @@ export async function runCredentialsCli(argv, overrides) {
222
223
  deps.log(result.message);
223
224
  deps.log("");
224
225
  deps.log("To migrate it, re-run with --write-credentials:");
225
- deps.log(" npx -y @bridge_gpt/mcp-server credentials migrate-agent-config --write-credentials");
226
+ deps.log(` npx -y ${MCP_PACKAGE_NAME} credentials migrate-agent-config --write-credentials`);
226
227
  if (result.candidates && result.candidates.length > 0) {
227
228
  deps.log("");
228
229
  deps.log("Discovered source(s):");
package/build/doctor.js CHANGED
@@ -28,11 +28,11 @@ import { createBridgeApiUrls } from "./bridge-api-urls.js";
28
28
  import { probeToolSurface } from "./tool-surface-gating.js";
29
29
  import { resolveBapiCredentials } from "./credential-store.js";
30
30
  import { resolveConductorBridgeApiAccess } from "./conductor/bridge-api-client.js";
31
- import { getIndexBranch } from "./conduct-epic/bridge-client.js";
32
31
  import { resolveConductEpicStateDirectory } from "./conduct-epic/checkpoint-store.js";
33
32
  import { isConductEpicLockOwnerAlive, parseConductEpicLock, } from "./conduct-epic/lock.js";
34
33
  import { resolveRequiredStartTicketsRepoName } from "./start-tickets-repo.js";
35
- import { BRIDGE_PACKAGE_NAME, describeLauncherReason, inspectLauncherConfigs, parseLauncherPin as parseSharedLauncherPin, } from "./launcher-config-inspection.js";
34
+ import { BRIDGE_PACKAGE_NAME, describeLauncherReason, DUPLICATE_REGISTRATION_GUIDANCE, inspectLauncherConfigs, parseLauncherPin as parseSharedLauncherPin, } from "./launcher-config-inspection.js";
35
+ import { MCP_SERVER_NAME, MCP_PACKAGE_NAME, } from "./mcp-identity.js";
36
36
  import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, } from "./agent-registry.js";
37
37
  import { getDoctorPrereqDescriptors, probePrerequisite, resolveWorktrunkBinary, } from "./start-tickets-prereqs.js";
38
38
  import { resolveProfiles } from "./mcp-profile.js";
@@ -50,7 +50,7 @@ export const DOCTOR_REPORT_TITLE = "bridge doctor — read-only diagnostics";
50
50
  export function getDoctorUsage() {
51
51
  return [
52
52
  "Usage:",
53
- " npx -y @bridge_gpt/mcp-server doctor [--agent <name>]",
53
+ ` npx -y ${MCP_PACKAGE_NAME} doctor [--agent <name>]`,
54
54
  "",
55
55
  // BAPI-669 (U9b): `doctor` is the general Bridge diagnostic command, not a
56
56
  // start-tickets-only one — and its report leads with install status, so the
@@ -72,7 +72,7 @@ export function getDoctorUsage() {
72
72
  "the start-tickets preflight prerequisites plus",
73
73
  "uv, the selected agent's command, Bridge API credential resolution, and",
74
74
  "worktree MCP registration reachability, and a read-only check of whether the",
75
- "Claude user config (~/.claude.json) registers a 'bridge-api' MCP server that",
75
+ `Claude user config (~/.claude.json) registers a '${MCP_SERVER_NAME}' MCP server that`,
76
76
  "could shadow the registration provisioned into a worker worktree. That check",
77
77
  "only reads the file and runs `git worktree list --porcelain`; it never edits,",
78
78
  "migrates, or writes any configuration. Credential resolution reports the",
@@ -428,7 +428,14 @@ export async function inspectLauncherCache(deps) {
428
428
  // but it is still a finding — doctor cannot determine its pin, and saying
429
429
  // nothing would read as "this config is fine". Classify it before the
430
430
  // no-entry skip below.
431
- if (found.action === "invalid" || found.action === "unsupported") {
431
+ //
432
+ // `duplicate-registration` is included (BAPI-807) because its pin is
433
+ // genuinely indeterminate: there are two entries and doctor must not pick
434
+ // one. Omitting it here would fall through to the probe below with a null
435
+ // spec — the registration-status section reports the actual fault.
436
+ if (found.action === "invalid" ||
437
+ found.action === "unsupported" ||
438
+ found.action === "duplicate-registration") {
432
439
  inspections.push({
433
440
  relPath,
434
441
  spec,
@@ -494,11 +501,79 @@ export async function inspectLauncherCache(deps) {
494
501
  }
495
502
  return inspections;
496
503
  }
504
+ /** The exact INFO body every legacy-registration finding renders (pinned text). */
505
+ export const LEGACY_REGISTRATION_INFO_TEXT = `This registration remains supported. New installs use \`${MCP_SERVER_NAME}\`. No action is required.`;
506
+ /**
507
+ * Select registration-status findings from shared launcher inspections.
508
+ *
509
+ * Pure: it derives from inspections that were already computed read-only, and
510
+ * performs no I/O of its own.
511
+ */
512
+ export function collectRegistrationStatusFindings(inspections) {
513
+ const findings = [];
514
+ for (const found of inspections) {
515
+ if (!found.filePresent)
516
+ continue;
517
+ if (found.action === "duplicate-registration") {
518
+ findings.push({ relPath: found.relPath, kind: "duplicate" });
519
+ continue;
520
+ }
521
+ if (found.legacyRegistration && found.registrationKey) {
522
+ findings.push({
523
+ relPath: found.relPath,
524
+ kind: "legacy",
525
+ registrationKey: found.registrationKey,
526
+ });
527
+ }
528
+ }
529
+ return findings;
530
+ }
531
+ /**
532
+ * True when any finding is a duplicate registration — the ONE registration-status
533
+ * state that affects doctor's exit code.
534
+ */
535
+ export function hasDuplicateRegistrationFault(findings) {
536
+ return findings.some((f) => f.kind === "duplicate");
537
+ }
538
+ /**
539
+ * Collect registration-status findings read-only, reusing the shared launcher
540
+ * inspection. Takes the same injected deps as the cache probe minus the npx
541
+ * probe — this section spawns nothing at all.
542
+ */
543
+ export async function inspectRegistrationStatus(deps) {
544
+ const shared = await inspectLauncherConfigs({
545
+ cwd: deps.cwd,
546
+ targetVersion: VERSION,
547
+ readFile: deps.readFile,
548
+ });
549
+ return collectRegistrationStatusFindings(shared);
550
+ }
551
+ /** Render the registration-status section of the doctor report (pure formatting). */
552
+ export function formatRegistrationStatusReport(findings) {
553
+ const lines = ["", "MCP registration status", ""];
554
+ if (findings.length === 0) {
555
+ lines.push(`OK Every inspected config registers Bridge as \`${MCP_SERVER_NAME}\`.`);
556
+ return lines.join("\n");
557
+ }
558
+ for (const finding of findings) {
559
+ if (finding.kind === "legacy") {
560
+ // Semantic status FIRST, then the affected path and the exact key, with
561
+ // code-like values in backticks.
562
+ lines.push(`INFO Supported legacy registration — \`${finding.relPath}\` registers Bridge as ` +
563
+ `\`${finding.registrationKey}\`.`);
564
+ lines.push(` ${LEGACY_REGISTRATION_INFO_TEXT}`);
565
+ continue;
566
+ }
567
+ lines.push(`ERROR Duplicate Bridge registrations detected — \`${finding.relPath}\`.`);
568
+ lines.push(` ${DUPLICATE_REGISTRATION_GUIDANCE}`);
569
+ }
570
+ return lines.join("\n");
571
+ }
497
572
  /** Render the launcher-cache section of the doctor report (pure formatting). */
498
573
  export function formatLauncherCacheReport(inspections) {
499
574
  const lines = ["", "Launcher cache (MCP cold-start readiness)", ""];
500
575
  if (inspections.length === 0) {
501
- lines.push("No project-local bridge-api launcher configs found to inspect.");
576
+ lines.push(`No project-local ${MCP_SERVER_NAME} launcher configs found to inspect.`);
502
577
  return lines.join("\n");
503
578
  }
504
579
  const labels = {
@@ -645,7 +720,7 @@ export function formatLocalCliOverrideDiagnosticReport(diagnostic) {
645
720
  const lines = ["", `Local CLI launcher (${LOCAL_CLI_OVERRIDE_ENV})`, ""];
646
721
  switch (diagnostic.state) {
647
722
  case "not-configured":
648
- lines.push("NOT CONFIGURED packaged launcher in use (npx -y @bridge_gpt/mcp-server)");
723
+ lines.push(`NOT CONFIGURED packaged launcher in use (npx -y ${MCP_PACKAGE_NAME})`);
649
724
  lines.push(" No local override is set, so no launcher probe was run.");
650
725
  break;
651
726
  case "executable":
@@ -1062,7 +1137,6 @@ export async function collectConductEpicDiagnostic(deps) {
1062
1137
  const resolveAccess = deps.resolveAccess ?? resolveConductorBridgeApiAccess;
1063
1138
  let repo = null;
1064
1139
  let bridgeCredentialResolved = false;
1065
- let accessForIndexProbe = null;
1066
1140
  try {
1067
1141
  const result = await resolveAccess({
1068
1142
  env: deps.env,
@@ -1075,7 +1149,6 @@ export async function collectConductEpicDiagnostic(deps) {
1075
1149
  if (result.ok) {
1076
1150
  repo = result.access.repoName;
1077
1151
  bridgeCredentialResolved = true;
1078
- accessForIndexProbe = result;
1079
1152
  }
1080
1153
  }
1081
1154
  catch {
@@ -1124,21 +1197,6 @@ export async function collectConductEpicDiagnostic(deps) {
1124
1197
  locks.push({ epic, owner_pid: owner.owner_pid, host: owner.host, alive });
1125
1198
  }
1126
1199
  }
1127
- let indexOverrideBranch = null;
1128
- let indexOverrideChecked = false;
1129
- if (accessForIndexProbe !== null && accessForIndexProbe.ok) {
1130
- const probe = deps.getIndexBranch ?? getIndexBranch;
1131
- try {
1132
- const result = await probe(accessForIndexProbe.access);
1133
- if (result.ok) {
1134
- indexOverrideChecked = true;
1135
- indexOverrideBranch = result.value.override?.override_branch ?? null;
1136
- }
1137
- }
1138
- catch {
1139
- indexOverrideChecked = false;
1140
- }
1141
- }
1142
1200
  return {
1143
1201
  ghPresent,
1144
1202
  ghAuthenticated,
@@ -1150,8 +1208,6 @@ export async function collectConductEpicDiagnostic(deps) {
1150
1208
  stateDirectoryReadable,
1151
1209
  checkpoints,
1152
1210
  locks,
1153
- indexOverrideBranch,
1154
- indexOverrideChecked,
1155
1211
  };
1156
1212
  }
1157
1213
  /** Render the conduct-epic section. Advisory: never changes the exit code. */
@@ -1206,18 +1262,8 @@ export function formatConductEpicDiagnosticReport(diagnostic) {
1206
1262
  }
1207
1263
  }
1208
1264
  }
1209
- if (!diagnostic.indexOverrideChecked) {
1210
- lines.push(" SKIP Indexed-branch override: not checked.");
1211
- }
1212
- else if (diagnostic.indexOverrideBranch === null) {
1213
- lines.push(" OK Indexed-branch override: none active.");
1214
- }
1215
- else {
1216
- lines.push(` WARN Indexed-branch override: active on '${diagnostic.indexOverrideBranch}'.\n` +
1217
- " If no epic is running, this is stale — run `conduct-epic finish <EPIC>`.");
1218
- }
1219
- lines.push(" Read-only: this section probes commands, reads local state, and issues one");
1220
- lines.push(" GET. It recovers no lock, rewrites no checkpoint, and never changes the exit code.");
1265
+ lines.push(" Read-only: this section probes commands and reads local state. It recovers no");
1266
+ lines.push(" lock, rewrites no checkpoint, and never changes the exit code.");
1221
1267
  return lines.join("\n");
1222
1268
  }
1223
1269
  /**
@@ -1314,6 +1360,25 @@ export async function runDoctorCli(argv, overrides = {}) {
1314
1360
  /* adapter diagnostics are advisory; never block the doctor report */
1315
1361
  }
1316
1362
  }
1363
+ // Registration-status diagnostics (BAPI-807). Strictly read-only and spawns
1364
+ // nothing. UNLIKE every other advisory section, one of its outcomes CAN change
1365
+ // the exit code: a duplicate registration is an actionable fault that leaves
1366
+ // Bridge unable to reconcile the config at all, so `doctor` must not exit 0 on
1367
+ // it. A supported legacy registration is information only and never does.
1368
+ let duplicateRegistrationFault = false;
1369
+ try {
1370
+ const registrationFindings = await inspectRegistrationStatus({
1371
+ cwd: overrides.launcherProbe?.cwd ?? deps.cwd,
1372
+ readFile: overrides.launcherProbe?.readFile ?? ((p) => readFile(p, "utf-8")),
1373
+ });
1374
+ duplicateRegistrationFault = hasDuplicateRegistrationFault(registrationFindings);
1375
+ log(formatRegistrationStatusReport(registrationFindings));
1376
+ }
1377
+ catch {
1378
+ // An unexpected failure must not invent a fault, and must not drop the
1379
+ // section silently either — but it also cannot claim the configs are clean.
1380
+ log(formatRegistrationStatusReport([]));
1381
+ }
1317
1382
  // Strictly read-only launcher-cache diagnostics (BAPI-451). Best-effort: a probe
1318
1383
  // failure never changes the doctor exit code (cold-start readiness is advisory,
1319
1384
  // not a hard prerequisite). The exit code remains driven by required prereqs.
@@ -1480,7 +1545,6 @@ export async function runDoctorCli(argv, overrides = {}) {
1480
1545
  readdir: overrides.conductEpic?.readdir ?? ((p) => readdir(p)),
1481
1546
  runCommand: overrides.conductEpic?.runCommand ?? deps.runCommand,
1482
1547
  resolveAccess: overrides.conductEpic?.resolveAccess,
1483
- getIndexBranch: overrides.conductEpic?.getIndexBranch,
1484
1548
  isProcessAlive: overrides.conductEpic?.isProcessAlive,
1485
1549
  };
1486
1550
  log(formatConductEpicDiagnosticReport(await collectConductEpicDiagnostic(conductDeps)));
@@ -1499,12 +1563,14 @@ export async function runDoctorCli(argv, overrides = {}) {
1499
1563
  stateDirectoryReadable: false,
1500
1564
  checkpoints: [],
1501
1565
  locks: [],
1502
- indexOverrideBranch: null,
1503
- indexOverrideChecked: false,
1504
1566
  }));
1505
1567
  }
1506
1568
  }
1507
1569
  if (!collection.ok)
1508
1570
  return 1;
1571
+ // BAPI-807: a duplicate registration fails the run alongside a missing
1572
+ // prerequisite. A supported legacy registration contributes nothing here.
1573
+ if (duplicateRegistrationFault)
1574
+ return 1;
1509
1575
  return collection.results.some((r) => !r.found) ? 1 : 0;
1510
1576
  }
@@ -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
@@ -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
+ }
@@ -82,6 +82,75 @@ export async function markExecutorJobLogFinished(jobId, finishedAt, deps) {
82
82
  const filePath = jobLogRecordPath(stateDir, jobId, deps.platform ?? process.platform);
83
83
  await deps.writeFile(filePath, JSON.stringify(normalizeRecord(updated), null, 2));
84
84
  }
85
+ // ---------------------------------------------------------------------------
86
+ // Active worker logs (BAPI-828)
87
+ // ---------------------------------------------------------------------------
88
+ /**
89
+ * In-memory map of job id → worker-log path for jobs whose tee is currently open.
90
+ *
91
+ * DELIBERATELY SEPARATE FROM THE PERSISTED RECORD ABOVE, and deliberately not
92
+ * derived from it. The persisted record is a visibility nicety for a SEPARATE
93
+ * `executor watch` process, it is written with best-effort error swallowing, and
94
+ * it keeps its row after `finished_at` is stamped. This map answers a different
95
+ * question — "which worker logs can be appended to right now, in THIS process" —
96
+ * and the suspend diagnostic depends on that answer being correct. Deriving it
97
+ * from the persisted record would mean a failed registry write silently disables
98
+ * suspend annotation for a worker whose tee is perfectly healthy.
99
+ *
100
+ * Process-scoped module state rather than runner-scoped, unlike the live worker
101
+ * registry: entries are keyed by server-minted job id, so two concurrent
102
+ * `runExecutor` invocations in one process cannot collide, and each value is a
103
+ * path only its own job ever deactivates.
104
+ *
105
+ * SECRET-FREE: a filesystem path inside the job's own worktree, nothing else.
106
+ */
107
+ const activeJobLogPaths = new Map();
108
+ /**
109
+ * Mark a job's worker log as open and appendable.
110
+ *
111
+ * Called once the tee EXISTS, before any registry persistence — see the map's
112
+ * docstring for why the two are not chained. Re-activating the same job id simply
113
+ * overwrites the path.
114
+ */
115
+ export function activateJobLog(jobId, logPath) {
116
+ if (typeof logPath !== "string" || logPath.length === 0)
117
+ return;
118
+ activeJobLogPaths.set(jobId, logPath);
119
+ }
120
+ /** Stop appending to a job's worker log. Idempotent. */
121
+ export function deactivateJobLog(jobId) {
122
+ activeJobLogPaths.delete(jobId);
123
+ }
124
+ /** The distinct currently-appendable log paths (a snapshot, safe to iterate). */
125
+ export function activeJobLogPathsSnapshot() {
126
+ return [...new Set(activeJobLogPaths.values())];
127
+ }
128
+ /**
129
+ * Append one diagnostic line to every currently active worker log.
130
+ *
131
+ * FAIL-OPEN PER PATH. Each append is contained independently, so one missing,
132
+ * deleted, or unwritable log cannot suppress the diagnostic for the others and
133
+ * cannot propagate an error into the executor's poll loop — this is annotation,
134
+ * never correctness.
135
+ *
136
+ * Paths are DEDUPLICATED (two jobs can legitimately name the same log during a
137
+ * re-entry window) and exactly one trailing newline is written, whether or not
138
+ * the caller supplied one.
139
+ */
140
+ export async function appendToActiveJobLogs(line, deps) {
141
+ const paths = activeJobLogPathsSnapshot();
142
+ if (paths.length === 0)
143
+ return;
144
+ const payload = `${line.replace(/\n+$/, "")}\n`;
145
+ await Promise.all(paths.map(async (logPath) => {
146
+ try {
147
+ await deps.appendFile(logPath, payload);
148
+ }
149
+ catch {
150
+ /* one unwritable log never blocks the others, or the executor */
151
+ }
152
+ }));
153
+ }
85
154
  /**
86
155
  * Load and validate a record for `executor watch <job>`. Returns a structured
87
156
  * missing/invalid result rather than throwing raw filesystem/JSON errors.