@bridge_gpt/mcp-server 0.2.38 → 0.2.41

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 (85) hide show
  1. package/README.md +189 -14
  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 +479 -0
  14. package/build/conduct-epic/cli.js +1765 -0
  15. package/build/conduct-epic/lock.js +302 -0
  16. package/build/conduct-epic/pr-state.js +286 -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/permissions.js +25 -6
  67. package/build/sfcc/reads-site-preference.js +6 -0
  68. package/build/sfcc/register.js +61 -23
  69. package/build/sfcc/registration-inventory.js +89 -0
  70. package/build/sfcc/setup-status.js +18 -34
  71. package/build/sfcc/tool-wrapper.js +294 -17
  72. package/build/sfcc/write-grants.js +33 -1
  73. package/build/sfcc/write-guard.js +41 -12
  74. package/build/sfcc/writes-custom-object-def.js +6 -2
  75. package/build/sfcc/writes-site-preference.js +6 -1
  76. package/build/sfcc/writes-system-object.js +11 -2
  77. package/build/sfcc/writes.js +13 -8
  78. package/build/start-tickets-prereqs.js +25 -15
  79. package/build/start-tickets.js +123 -21
  80. package/build/version.generated.js +1 -1
  81. package/build/worktree-core.js +9 -3
  82. package/docs/install/mcp-tool-integrations.md +54 -9
  83. package/docs/install/sfcc-integration.md +71 -24
  84. package/package.json +3 -3
  85. package/build/executor/worker-config-isolation.js +0 -287
package/build/doctor.js CHANGED
@@ -21,18 +21,24 @@ import path from "path";
21
21
  import { createDefaultStartTicketsDeps } from "./start-tickets.js";
22
22
  import { VERSION } from "./version.generated.js";
23
23
  import { collectInstallStatusChecks, formatInstallStatusReport, formatInstallStatusFallbackReport, resolveInstallDoctorTarget, } from "./install-doctor.js";
24
+ import { inspectExecutorAgentAdapter, inspectionForFailedResolution, } from "./agent-launchers/executor-adapter-inspection.js";
25
+ import { resolveExecutorAgentAdapter } from "./agent-launchers/executor-adapter-registry.js";
24
26
  import { parseDefaultOnEnvFlag } from "./env-flags.js";
25
27
  import { createBridgeApiUrls } from "./bridge-api-urls.js";
26
28
  import { probeToolSurface } from "./tool-surface-gating.js";
27
29
  import { resolveBapiCredentials } from "./credential-store.js";
30
+ import { resolveConductorBridgeApiAccess } from "./conductor/bridge-api-client.js";
31
+ import { getIndexBranch } from "./conduct-epic/bridge-client.js";
32
+ import { resolveConductEpicStateDirectory } from "./conduct-epic/checkpoint-store.js";
33
+ import { isConductEpicLockOwnerAlive, parseConductEpicLock, } from "./conduct-epic/lock.js";
28
34
  import { resolveRequiredStartTicketsRepoName } from "./start-tickets-repo.js";
29
35
  import { BRIDGE_PACKAGE_NAME, describeLauncherReason, inspectLauncherConfigs, parseLauncherPin as parseSharedLauncherPin, } from "./launcher-config-inspection.js";
30
36
  import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, } from "./agent-registry.js";
31
- import { getDoctorPrereqDescriptors, probePrerequisite, } from "./start-tickets-prereqs.js";
37
+ import { getDoctorPrereqDescriptors, probePrerequisite, resolveWorktrunkBinary, } from "./start-tickets-prereqs.js";
32
38
  import { resolveProfiles } from "./mcp-profile.js";
33
39
  import { executorLaunchdDirForHome, executorSystemdDirForHome, executorIdFromLaunchdFilename, executorIdFromSystemdFilename, inspectExecutorServiceArtifact, EXECUTOR_SERVICE_BASE_URL_ENV, } from "./executor/index.js";
34
40
  import { DEFAULT_BAPI_BASE_URL } from "./executor/credentials.js";
35
- import { evaluateWorkerConfigIsolationCapability, formatWorkerConfigIsolationReason, } from "./executor/worker-config-isolation.js";
41
+ import { detectClaudeLogin, formatClaudeLoginAdvisory } from "./claude-login.js";
36
42
  /**
37
43
  * The report/usage title (BAPI-669, U9b). `doctor` diagnoses the whole Bridge
38
44
  * install — install status, prerequisites, launcher cache, and tool surface — so it
@@ -75,14 +81,28 @@ export function getDoctorUsage() {
75
81
  "migrate a credential, use /install-bridge or the `credentials` subcommand —",
76
82
  "doctor stays strictly read-only.",
77
83
  "",
78
- "On a host with a configured conductor executor service it also reports",
79
- "whether worker config isolation is healthy: whether this platform and",
80
- "authentication layout allow a worker to run with an executor-owned",
81
- "CLAUDE_CONFIG_DIR, so operator-scoped MCP servers are unreachable from a",
82
- "worker. This check is a read-only capability evaluation: it never seeds or",
83
- "removes an isolated directory, never reads the credential store, and never",
84
- "prints a credential value. It affects the exit code only when an executor",
85
- "service is configured; otherwise it is advisory.",
84
+ // BAPI-815/R4: the old text here promised a 'Worker config isolation",
85
+ // (conductor executor)' section naming a CLAUDE_CONFIG_DIR strategy. BAPI-790
86
+ // replaced per-job config-directory isolation with --strict-mcp-config argv
87
+ // scoping, and BAPI-791 deleted that doctor section together with the whole
88
+ // worker-auth strategy vocabulary. The section it described exists in no
89
+ // doctor output, so the claim is removed rather than reworded.
90
+ "Worker containment is no longer reported here. A conductor worker is scoped",
91
+ "at spawn time with `--strict-mcp-config --mcp-config <file>`, and the",
92
+ "executor asserts that scope per spawn from the worker's own system/init",
93
+ "event — failing the job with ContractError.McpSurfaceMismatch on a confirmed",
94
+ "leak. That per-spawn assertion, not a doctor health line, is the containment",
95
+ "proof; see docs/claude/worker-config-isolation-drill.md.",
96
+ "",
97
+ "It also includes an advisory 'Local CLI launcher' section (BAPI-815): whether",
98
+ "the BAPI_MCP_CLI override is set, and whether the launcher it names can be",
99
+ "executed. When the override is unset, NO launcher probe runs at all. When it",
100
+ "is set, doctor runs that launcher exactly once with a single appended",
101
+ "--version argument through an array-based spawn — never a shell — reads only",
102
+ "its exit status, and discards its stdout and stderr so local output cannot",
103
+ "enter this report. A value containing shell operators or an unbalanced quote",
104
+ "is rejected without running anything. The whole section is advisory: a",
105
+ "missing, malformed, or unrunnable override never changes the exit code.",
86
106
  "",
87
107
  "It also includes an advisory 'MCP tool surface' section (BAPI-641): what",
88
108
  "dynamic capability gating would advertise for this repo. It performs at most",
@@ -262,6 +282,84 @@ export function formatDoctorReport(platform, agent, collection) {
262
282
  formatDoctorPrereqSection(platform, collection),
263
283
  ].join("\n");
264
284
  }
285
+ /**
286
+ * Inspect the selected agent's executor adapter.
287
+ *
288
+ * Probes the executable version (cheap, read-only) but NOT deny enforcement:
289
+ * that probe spawns a real headless agent, and `conductor doctor` already owns
290
+ * that diagnostic behind its own `--no-deny-probe` switch. Duplicating it here
291
+ * would make a plain `doctor` run spawn an agent the operator did not ask for.
292
+ */
293
+ export async function collectExecutorAdapterDiagnostic(agentName, deps) {
294
+ const resolve = deps.resolveAdapter ?? ((id) => resolveExecutorAgentAdapter(id));
295
+ const resolution = resolve(agentName);
296
+ if (!resolution.supported) {
297
+ // An unsupported agent is REPORTED as unsupported, never substituted with
298
+ // Claude's metadata. Showing Claude's adapter under another agent's name
299
+ // would tell an operator their agent is ready when nothing implements it.
300
+ return inspectionForFailedResolution(agentName, resolution);
301
+ }
302
+ const result = await inspectExecutorAgentAdapter(resolution.adapter, { platform: deps.platform, env: deps.env, runCommand: deps.runCommand }, { probeExecutable: true });
303
+ return result.inspection;
304
+ }
305
+ /** Render the adapter section. Pure formatting — no probing. */
306
+ export function formatExecutorAdapterDiagnosticReport(inspection) {
307
+ const lines = [];
308
+ lines.push("");
309
+ lines.push("Executor agent adapter (conductor worker spawning)");
310
+ lines.push("─────────────────────────────────────────────────");
311
+ lines.push(`agent: ${inspection.identity.agentId}`);
312
+ lines.push(`adapter: ${inspection.identity.adapterId}`);
313
+ lines.push(`adapter version: ${inspection.identity.adapterVersion}`);
314
+ lines.push(`strategy: ${inspection.identity.strategyId}`);
315
+ if (inspection.executable) {
316
+ const exe = inspection.executable;
317
+ const version = exe.version.length > 0 ? exe.version : "n/a";
318
+ lines.push(`executable: ${exe.command}`);
319
+ lines.push(`runtime version: ${version}`);
320
+ lines.push(`fingerprint: ${exe.fingerprint ?? "n/a"}`);
321
+ if (exe.failure)
322
+ lines.push(`executable probe: FAILED (${exe.failure})`);
323
+ }
324
+ if (inspection.platform) {
325
+ lines.push(inspection.platform.supported
326
+ ? `platform: ${inspection.platform.platform} [SUPPORTED]`
327
+ : `platform: ${inspection.platform.platform} [UNSUPPORTED] ${inspection.platform.message}`);
328
+ }
329
+ if (inspection.capabilities.length > 0) {
330
+ lines.push("capabilities:");
331
+ for (const capability of inspection.capabilities) {
332
+ const status = capability.supported ? "OK " : "MISSING ";
333
+ const strategy = capability.strategyId ? ` (${capability.strategyId})` : "";
334
+ const reason = capability.reason ? ` — ${capability.reason}` : "";
335
+ lines.push(` ${status} ${capability.capability}${strategy}${reason}`);
336
+ }
337
+ }
338
+ lines.push(`lifecycle: ${inspection.lifecycleKind ?? "NOT DECLARED"}`);
339
+ if (inspection.auth) {
340
+ // Names, ownership, and billing class only — never a value.
341
+ const managed = inspection.auth.managedCarriers;
342
+ lines.push(managed.length === 0
343
+ ? "bridge-managed creds: none (workers use the operator's own login)"
344
+ : `bridge-managed creds: ${managed
345
+ .map((c) => `${c.carrierId} [${c.billingClass}] env=${c.secretEnvNames.join(",")}`)
346
+ .join("; ")}`);
347
+ lines.push(inspection.auth.passthroughs.length === 0
348
+ ? "operator passthroughs: none"
349
+ : `operator passthroughs: ${inspection.auth.passthroughs
350
+ .map((p) => `${p.envName} [${p.billingClass}, ${p.rule}]`)
351
+ .join("; ")}`);
352
+ }
353
+ if (!inspection.ok) {
354
+ lines.push("");
355
+ lines.push("This agent CANNOT spawn conductor workers. A missing or unsupported capability is " +
356
+ "refused outright — no other agent's behavior is substituted.");
357
+ }
358
+ for (const warning of inspection.warnings) {
359
+ lines.push(` - ${warning}`);
360
+ }
361
+ return lines.join("\n");
362
+ }
265
363
  /**
266
364
  * Extract the `@bridge_gpt/mcp-server[@version]` launcher spec from an args array.
267
365
  *
@@ -418,6 +516,159 @@ export function formatLauncherCacheReport(inspections) {
418
516
  }
419
517
  return lines.join("\n");
420
518
  }
519
+ // ---------------------------------------------------------------------------
520
+ // Local CLI launcher override (BAPI-815/R5) — advisory, strictly read-only.
521
+ //
522
+ // `BAPI_MCP_CLI` lets a packaged command shell out to a LOCAL build instead of
523
+ // `npx -y @bridge_gpt/mcp-server`, so an executor or CLI change can be verified
524
+ // before an npm publish. That is useful precisely because it is invisible: the
525
+ // override lives in one environment variable, and `ping` cannot tell a published
526
+ // 0.2.x from a local build at the same version. This section makes it visible.
527
+ //
528
+ // The probe is deliberately narrow. It runs the configured launcher ONCE with a
529
+ // single appended `--version` argument, through the injected array-based command
530
+ // runner — never a shell, never `eval`, never a concatenated command string. The
531
+ // probe's stdout and stderr are discarded after its exit status is read, so
532
+ // arbitrary local output cannot enter the doctor report. It is ADVISORY: a
533
+ // missing, malformed, or unrunnable override never changes the exit code.
534
+ // ---------------------------------------------------------------------------
535
+ /** The environment variable carrying the local launcher override. */
536
+ export const LOCAL_CLI_OVERRIDE_ENV = "BAPI_MCP_CLI";
537
+ /**
538
+ * Longest configured value echoed back into the report. The contract forbids
539
+ * putting a credential in the launcher, but a bound is cheap insurance against
540
+ * a report that dumps an arbitrarily long environment value either way.
541
+ */
542
+ const LOCAL_CLI_OVERRIDE_DISPLAY_LIMIT = 200;
543
+ /**
544
+ * Characters that would turn the value into a shell command LINE rather than a
545
+ * command plus arguments. This runner never uses a shell, so these could not be
546
+ * interpreted anyway — but a value containing them means the operator EXPECTED
547
+ * shell semantics, and silently running only the first word would be a worse
548
+ * answer than refusing. Parentheses and braces are deliberately absent: they
549
+ * appear in ordinary macOS and Windows paths and are inert without a shell.
550
+ */
551
+ const SHELL_OPERATOR_PATTERN = /[;&|<>$`\n\r]/;
552
+ /**
553
+ * Split the override into an executable and its fixed arguments.
554
+ *
555
+ * A deliberately small, non-shell tokenizer: whitespace separates tokens, and
556
+ * single or double quotes group a token so a launcher path containing spaces
557
+ * (`node "/Applications/My Build/index.js"`) survives intact. It performs no
558
+ * expansion of any kind — no globbing, no variable substitution, no command
559
+ * substitution — because the result is passed to an array-based spawn where
560
+ * none of that would be interpreted.
561
+ */
562
+ export function parseLocalCliLauncher(raw) {
563
+ if (SHELL_OPERATOR_PATTERN.test(raw)) {
564
+ return { ok: false, reason: "contains shell operators, which are not supported" };
565
+ }
566
+ const tokens = [];
567
+ let current = "";
568
+ let quote = null;
569
+ let started = false;
570
+ for (const ch of raw) {
571
+ if (quote) {
572
+ if (ch === quote)
573
+ quote = null;
574
+ else
575
+ current += ch;
576
+ continue;
577
+ }
578
+ if (ch === '"' || ch === "'") {
579
+ quote = ch;
580
+ started = true;
581
+ continue;
582
+ }
583
+ if (ch === " " || ch === "\t") {
584
+ if (started) {
585
+ tokens.push(current);
586
+ current = "";
587
+ started = false;
588
+ }
589
+ continue;
590
+ }
591
+ current += ch;
592
+ started = true;
593
+ }
594
+ if (quote)
595
+ return { ok: false, reason: "has an unbalanced quote" };
596
+ if (started)
597
+ tokens.push(current);
598
+ const [executable, ...args] = tokens;
599
+ if (!executable)
600
+ return { ok: false, reason: "is empty after parsing" };
601
+ return { ok: true, executable, args };
602
+ }
603
+ /** Truncate a configured value for display without ever dropping it silently. */
604
+ function boundConfiguredValue(raw) {
605
+ if (raw.length <= LOCAL_CLI_OVERRIDE_DISPLAY_LIMIT)
606
+ return raw;
607
+ return `${raw.slice(0, LOCAL_CLI_OVERRIDE_DISPLAY_LIMIT)}… (truncated)`;
608
+ }
609
+ /**
610
+ * Collect the advisory local-launcher diagnostic. Read-only and bounded: at most
611
+ * ONE `--version` invocation, and only when an override is actually configured.
612
+ *
613
+ * The "no override ⇒ no probe" rule is load-bearing beyond tidiness — doctor
614
+ * pins that an unsupported platform issues ZERO command probes, and a section
615
+ * that probed unconditionally would break that guarantee.
616
+ */
617
+ export async function collectLocalCliOverrideDiagnostic(deps) {
618
+ const raw = deps.env[LOCAL_CLI_OVERRIDE_ENV];
619
+ if (typeof raw !== "string" || raw.trim().length === 0) {
620
+ return { state: "not-configured" };
621
+ }
622
+ const configured = boundConfiguredValue(raw.trim());
623
+ const parsed = parseLocalCliLauncher(raw.trim());
624
+ if (!parsed.ok) {
625
+ // Rejected WITHOUT running anything: an unsafe or malformed value must never
626
+ // reach the command runner, not even to discover that it fails.
627
+ return { state: "invalid", configured, reason: parsed.reason };
628
+ }
629
+ try {
630
+ const probe = await deps.runCommand(parsed.executable, [...parsed.args, "--version"]);
631
+ // Read the status, then drop the output. Nothing the local launcher printed
632
+ // is allowed into the report.
633
+ return probe.exitCode === 0
634
+ ? { state: "executable", configured }
635
+ : { state: "not-executable", configured };
636
+ }
637
+ catch {
638
+ // A spawn failure (missing file, not executable) is the same advisory
639
+ // outcome as a non-zero exit. The exception itself never escapes.
640
+ return { state: "not-executable", configured };
641
+ }
642
+ }
643
+ /** Render the local-launcher override section (pure formatting). */
644
+ export function formatLocalCliOverrideDiagnosticReport(diagnostic) {
645
+ const lines = ["", `Local CLI launcher (${LOCAL_CLI_OVERRIDE_ENV})`, ""];
646
+ switch (diagnostic.state) {
647
+ case "not-configured":
648
+ lines.push("NOT CONFIGURED packaged launcher in use (npx -y @bridge_gpt/mcp-server)");
649
+ lines.push(" No local override is set, so no launcher probe was run.");
650
+ break;
651
+ case "executable":
652
+ lines.push(`CONFIGURED ${diagnostic.configured}`);
653
+ lines.push(" The launcher ran successfully with --version.");
654
+ lines.push(" A stale local build is exactly as misleading as a stale npm publish —");
655
+ lines.push(" rebuild with `cd mcp_server && npm run build` before relying on it.");
656
+ break;
657
+ case "not-executable":
658
+ lines.push(`CONFIGURED ${diagnostic.configured}`);
659
+ lines.push(" The launcher could not be executed (--version did not succeed).");
660
+ lines.push(" Check that the path exists and has been built.");
661
+ break;
662
+ case "invalid":
663
+ lines.push(`CONFIGURED ${diagnostic.configured}`);
664
+ lines.push(` The value could not be used: it ${diagnostic.reason}.`);
665
+ lines.push(" No launcher was run. Expected form: node /absolute/path/to/index.js");
666
+ break;
667
+ }
668
+ lines.push(" Advisory only: this finding never blocks packaged CLI use and never changes");
669
+ lines.push(" the doctor exit code.");
670
+ return lines.join("\n");
671
+ }
421
672
  /**
422
673
  * Collect the read-only tool-surface diagnostic. Evaluates the kill switch first
423
674
  * and returns without any network request when disabled. For an enabled flag and
@@ -523,57 +774,6 @@ export function formatToolSurfaceDiagnosticReport(diag) {
523
774
  lines.push("to observe surface changes; no project MCP configuration change is required.");
524
775
  return lines.join("\n");
525
776
  }
526
- /**
527
- * Adapt the executor's read-only capability evaluator for doctor.
528
- *
529
- * READ-ONLY BY CONSTRUCTION: it forwards only `platform` and `env`, and
530
- * `evaluateWorkerConfigIsolationCapability` accepts no mutating dependency at
531
- * all — no directory-creation, permission-setting, file-writing, or removal
532
- * operation is reachable from it even in principle. No directory is seeded, no
533
- * credential value is read, and nothing is persisted. That is what preserves
534
- * doctor's read-only contract while still answering AC-4's health question.
535
- *
536
- * NOTE: this module is pinned by `tests/pytest/mcp_server/test_doctor_static_contract.py`,
537
- * which asserts the literal token `write` + `File` appears nowhere in this file —
538
- * so describe write operations in prose here rather than naming that API.
539
- */
540
- export function collectWorkerConfigIsolationDiagnostic(input) {
541
- const capability = evaluateWorkerConfigIsolationCapability({
542
- platform: input.platform,
543
- env: input.env,
544
- });
545
- if (capability.supported) {
546
- return {
547
- status: "healthy",
548
- platform: capability.platform,
549
- strategy: capability.strategy,
550
- required: input.executorConfigured,
551
- };
552
- }
553
- return {
554
- status: "unhealthy",
555
- platform: capability.platform,
556
- remediation: formatWorkerConfigIsolationReason(capability.platform, capability.reason),
557
- required: input.executorConfigured,
558
- };
559
- }
560
- /** Render the isolation section: fixed labels plus already-bounded reason text. */
561
- export function formatWorkerConfigIsolationDiagnosticReport(diagnostic) {
562
- const lines = ["", "Worker config isolation (conductor executor)"];
563
- if (diagnostic.status === "healthy") {
564
- lines.push(` HEALTHY ${diagnostic.platform}: isolation can be established (strategy: ${diagnostic.strategy})`);
565
- lines.push(" Workers run with an executor-owned CLAUDE_CONFIG_DIR, so operator-scoped");
566
- lines.push(" MCP servers are unreachable from a worker.");
567
- return lines.join("\n");
568
- }
569
- // An executor host cannot dispatch workers safely without isolation, so the
570
- // label distinguishes a blocking condition from an advisory one.
571
- lines.push(` ${diagnostic.required ? "UNHEALTHY" : "SKIP "} ${diagnostic.remediation ?? ""}`);
572
- if (!diagnostic.required) {
573
- lines.push(" Advisory only: no executor service is configured on this host.");
574
- }
575
- return lines.join("\n");
576
- }
577
777
  /** Where generated units live, and how their filenames encode the executor id. */
578
778
  function resolveExecutorServiceConvention(platform, homeDir) {
579
779
  if (platform === "darwin") {
@@ -765,8 +965,12 @@ export function formatExecutorServiceDiagnosticsReport(diagnostics) {
765
965
  return lines.join("\n");
766
966
  }
767
967
  export async function collectPlaneCredentialDiagnostic(deps) {
768
- const anthropic = deps.env.ANTHROPIC_API_KEY;
769
- const anthropicKeyPresent = typeof anthropic === "string" && anthropic.trim().length > 0;
968
+ // BAPI-791: worker authentication is no longer a plane-readiness question this
969
+ // section can answer a worker authenticates through the executor host's own
970
+ // interactive `claude login`, reported once elsewhere (see
971
+ // `formatClaudeLoginAdvisory`), not per-repo here. This section is now scoped
972
+ // to exactly what `plane up` still refuses on for THIS repository: a resolvable
973
+ // Bridge credential.
770
974
  const resolveRepo = deps.resolveRepoName ?? resolveRequiredStartTicketsRepoName;
771
975
  let repo = null;
772
976
  try {
@@ -778,7 +982,7 @@ export async function collectPlaneCredentialDiagnostic(deps) {
778
982
  /* sanitized: identity resolution failure is reported as an absent repo */
779
983
  }
780
984
  if (repo === null) {
781
- return { anthropicKeyPresent, repo: null, target: null, bridgeCredentialResolved: false };
985
+ return { repo: null, target: null, bridgeCredentialResolved: false };
782
986
  }
783
987
  // Resolve, then keep ONLY a label. The key never reaches the diagnostic.
784
988
  const status = await probeRepoCredential(repo, { platform: deps.platform, env: deps.env, homedir: deps.homedir, readdir: async () => [], readFile: deps.readFile, stat: deps.stat, resolveCredentials: deps.resolveCredentials }, {
@@ -792,7 +996,6 @@ export async function collectPlaneCredentialDiagnostic(deps) {
792
996
  },
793
997
  });
794
998
  return {
795
- anthropicKeyPresent,
796
999
  repo,
797
1000
  target: status.target,
798
1001
  bridgeCredentialResolved: status.resolved,
@@ -803,10 +1006,6 @@ export async function collectPlaneCredentialDiagnostic(deps) {
803
1006
  /** Render the plane readiness section. Advisory: never changes the exit code. */
804
1007
  export function formatPlaneCredentialDiagnosticReport(diagnostic) {
805
1008
  const lines = ["", "Conductor plane credentials (`plane up` — advisory)"];
806
- lines.push(diagnostic.anthropicKeyPresent
807
- ? " OK ANTHROPIC_API_KEY is set in this shell."
808
- : " MISSING ANTHROPIC_API_KEY is not set in this shell. Isolated workers have no OAuth\n" +
809
- " session and refuse to spawn without it, so `plane up` will refuse.");
810
1009
  if (diagnostic.repo === null) {
811
1010
  lines.push(" MISSING the Bridge repository identity could not be resolved. Set BAPI_REPO_NAME or\n" +
812
1011
  " add a valid .bridge/config at the repository root.");
@@ -823,6 +1022,204 @@ export function formatPlaneCredentialDiagnosticReport(diagnostic) {
823
1022
  lines.push(" no .bridge/plane/ directory, persists nothing, and starts no plane member.");
824
1023
  return lines.join("\n");
825
1024
  }
1025
+ /**
1026
+ * Collect conduct-epic readiness, read-only.
1027
+ *
1028
+ * The credential is resolved only to establish that it CAN be, and the resolved
1029
+ * access object is discarded immediately: only the repo name and a boolean
1030
+ * survive into the returned diagnostic, so no later formatting change can print
1031
+ * a key it never received.
1032
+ */
1033
+ export async function collectConductEpicDiagnostic(deps) {
1034
+ const isAlive = deps.isProcessAlive ?? isConductEpicLockOwnerAlive;
1035
+ let ghPresent = false;
1036
+ let ghAuthenticated = false;
1037
+ try {
1038
+ const version = await deps.runCommand("gh", ["--version"], { cwd: deps.cwd });
1039
+ ghPresent = version.exitCode === 0;
1040
+ }
1041
+ catch {
1042
+ ghPresent = false;
1043
+ }
1044
+ if (ghPresent) {
1045
+ try {
1046
+ const auth = await deps.runCommand("gh", ["auth", "status"], { cwd: deps.cwd });
1047
+ ghAuthenticated = auth.exitCode === 0;
1048
+ }
1049
+ catch {
1050
+ ghAuthenticated = false;
1051
+ }
1052
+ }
1053
+ const worktrunkBinary = resolveWorktrunkBinary(deps.platform, deps.env);
1054
+ let worktrunkAvailable = false;
1055
+ try {
1056
+ const probe = await deps.runCommand(worktrunkBinary, ["--version"], { cwd: deps.cwd });
1057
+ worktrunkAvailable = probe.exitCode === 0;
1058
+ }
1059
+ catch {
1060
+ worktrunkAvailable = false;
1061
+ }
1062
+ const resolveAccess = deps.resolveAccess ?? resolveConductorBridgeApiAccess;
1063
+ let repo = null;
1064
+ let bridgeCredentialResolved = false;
1065
+ let accessForIndexProbe = null;
1066
+ try {
1067
+ const result = await resolveAccess({
1068
+ env: deps.env,
1069
+ cwd: deps.cwd,
1070
+ homedir: deps.homedir,
1071
+ platform: deps.platform,
1072
+ readFile: deps.readFile,
1073
+ stat: deps.stat,
1074
+ });
1075
+ if (result.ok) {
1076
+ repo = result.access.repoName;
1077
+ bridgeCredentialResolved = true;
1078
+ accessForIndexProbe = result;
1079
+ }
1080
+ }
1081
+ catch {
1082
+ bridgeCredentialResolved = false;
1083
+ }
1084
+ let stateDirectory = null;
1085
+ let stateDirectoryReadable = false;
1086
+ const checkpoints = [];
1087
+ const locks = [];
1088
+ if (repo !== null) {
1089
+ stateDirectory = resolveConductEpicStateDirectory(repo, {
1090
+ env: deps.env,
1091
+ homedir: deps.homedir,
1092
+ });
1093
+ let entries = [];
1094
+ try {
1095
+ entries = await deps.readdir(stateDirectory);
1096
+ stateDirectoryReadable = true;
1097
+ }
1098
+ catch {
1099
+ stateDirectoryReadable = false;
1100
+ }
1101
+ for (const entry of entries) {
1102
+ if (entry.endsWith(".json"))
1103
+ checkpoints.push(entry);
1104
+ if (!entry.endsWith(".lock"))
1105
+ continue;
1106
+ const epic = entry.slice(0, -".lock".length);
1107
+ let raw;
1108
+ try {
1109
+ raw = await deps.readFile(path.join(stateDirectory, entry));
1110
+ }
1111
+ catch {
1112
+ locks.push({ epic, owner_pid: null, host: null, alive: null, problem: "unreadable" });
1113
+ continue;
1114
+ }
1115
+ const owner = parseConductEpicLock(raw);
1116
+ if (owner === null) {
1117
+ locks.push({ epic, owner_pid: null, host: null, alive: null, problem: "malformed or unsupported version" });
1118
+ continue;
1119
+ }
1120
+ // Liveness is only meaningful for a lock taken on THIS host.
1121
+ const alive = owner.host === deps.env.HOSTNAME || owner.host === os.hostname()
1122
+ ? isAlive(owner.owner_pid)
1123
+ : null;
1124
+ locks.push({ epic, owner_pid: owner.owner_pid, host: owner.host, alive });
1125
+ }
1126
+ }
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
+ return {
1143
+ ghPresent,
1144
+ ghAuthenticated,
1145
+ worktrunkBinary,
1146
+ worktrunkAvailable,
1147
+ repo,
1148
+ bridgeCredentialResolved,
1149
+ stateDirectory,
1150
+ stateDirectoryReadable,
1151
+ checkpoints,
1152
+ locks,
1153
+ indexOverrideBranch,
1154
+ indexOverrideChecked,
1155
+ };
1156
+ }
1157
+ /** Render the conduct-epic section. Advisory: never changes the exit code. */
1158
+ export function formatConductEpicDiagnosticReport(diagnostic) {
1159
+ const lines = ["", "conduct-epic readiness (advisory)"];
1160
+ lines.push(diagnostic.ghAuthenticated
1161
+ ? " OK gh: present and authenticated."
1162
+ : diagnostic.ghPresent
1163
+ ? " MISSING gh: present but NOT authenticated. Run `gh auth login`."
1164
+ : " MISSING gh: not found on PATH. `conduct-epic init` and `status` need it.");
1165
+ lines.push(diagnostic.worktrunkAvailable
1166
+ ? ` OK Worktrunk: '${diagnostic.worktrunkBinary}' resolves.`
1167
+ : ` MISSING Worktrunk: '${diagnostic.worktrunkBinary}' not found (honors BAPI_WORKTRUNK_BIN).`);
1168
+ if (diagnostic.repo === null) {
1169
+ lines.push(" MISSING Bridge identity: the repository could not be resolved. Set BAPI_REPO_NAME\n" +
1170
+ " or add a valid .bridge/config at the repository root.");
1171
+ }
1172
+ else if (diagnostic.bridgeCredentialResolved) {
1173
+ lines.push(` OK Bridge credential for ${diagnostic.repo}: resolved.`);
1174
+ }
1175
+ else {
1176
+ lines.push(` MISSING Bridge credential for ${diagnostic.repo}: NOT resolved.`);
1177
+ }
1178
+ if (diagnostic.stateDirectory === null) {
1179
+ lines.push(" SKIP Checkpoint directory: not resolved (no repository identity).");
1180
+ }
1181
+ else if (!diagnostic.stateDirectoryReadable) {
1182
+ lines.push(` SKIP Checkpoint directory ${diagnostic.stateDirectory}: absent or unreadable\n` +
1183
+ " (normal before the first `conduct-epic init`).");
1184
+ }
1185
+ else {
1186
+ lines.push(` OK Checkpoint directory ${diagnostic.stateDirectory}: readable, ` +
1187
+ `${diagnostic.checkpoints.length} checkpoint(s).`);
1188
+ }
1189
+ if (diagnostic.locks.length === 0) {
1190
+ lines.push(" OK Locks: none held.");
1191
+ }
1192
+ else {
1193
+ for (const lock of diagnostic.locks) {
1194
+ if (lock.problem !== undefined) {
1195
+ lines.push(` WARN Lock ${lock.epic}: ${lock.problem}. Inspect it by hand.`);
1196
+ }
1197
+ else if (lock.alive === false) {
1198
+ lines.push(` WARN Lock ${lock.epic}: owner pid ${lock.owner_pid} on ${lock.host} is gone (stale).\n` +
1199
+ " The next mutating verb recovers it automatically.");
1200
+ }
1201
+ else if (lock.alive === null) {
1202
+ lines.push(` WARN Lock ${lock.epic}: taken on host ${lock.host}; liveness is unknowable here.`);
1203
+ }
1204
+ else {
1205
+ lines.push(` OK Lock ${lock.epic}: held by live pid ${lock.owner_pid}.`);
1206
+ }
1207
+ }
1208
+ }
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.");
1221
+ return lines.join("\n");
1222
+ }
826
1223
  /**
827
1224
  * CLI entry for the read-only `doctor` subcommand. Returns a process exit code.
828
1225
  * Help returns 0; parser errors return 1; otherwise it prints the report and
@@ -886,6 +1283,37 @@ export async function runDoctorCli(argv, overrides = {}) {
886
1283
  }
887
1284
  }
888
1285
  log(formatDoctorPrereqSection(deps.platform, collection));
1286
+ // Executor-adapter section (BAPI-781). Advisory with respect to the EXIT CODE
1287
+ // — an unusable adapter is reported loudly but does not change doctor's
1288
+ // pass/fail, which stays driven by the start-tickets prerequisites this
1289
+ // command has always gated on. Read-only: one version probe, no writes, no
1290
+ // credential resolution. Any unexpected throw is swallowed so a diagnostic
1291
+ // cannot take down the report it is part of.
1292
+ if (overrides.executorAdapter !== false) {
1293
+ try {
1294
+ const adapterDeps = {
1295
+ platform: overrides.executorAdapter?.platform ?? deps.platform,
1296
+ env: overrides.executorAdapter?.env ?? deps.env,
1297
+ runCommand: overrides.executorAdapter?.runCommand ??
1298
+ (async (command, args) => {
1299
+ const probe = await deps.runCommand(command, args);
1300
+ return {
1301
+ stdout: probe.stdout ?? "",
1302
+ stderr: probe.stderr ?? "",
1303
+ exitCode: probe.exitCode ?? 1,
1304
+ };
1305
+ }),
1306
+ ...(overrides.executorAdapter?.resolveAdapter
1307
+ ? { resolveAdapter: overrides.executorAdapter.resolveAdapter }
1308
+ : {}),
1309
+ };
1310
+ const inspection = await collectExecutorAdapterDiagnostic(agent.name, adapterDeps);
1311
+ log(formatExecutorAdapterDiagnosticReport(inspection));
1312
+ }
1313
+ catch {
1314
+ /* adapter diagnostics are advisory; never block the doctor report */
1315
+ }
1316
+ }
889
1317
  // Strictly read-only launcher-cache diagnostics (BAPI-451). Best-effort: a probe
890
1318
  // failure never changes the doctor exit code (cold-start readiness is advisory,
891
1319
  // not a hard prerequisite). The exit code remains driven by required prereqs.
@@ -901,6 +1329,27 @@ export async function runDoctorCli(argv, overrides = {}) {
901
1329
  catch {
902
1330
  /* launcher-cache diagnostics are advisory; never block the doctor report */
903
1331
  }
1332
+ // Advisory local-launcher override section (BAPI-815/R5), placed beside the
1333
+ // launcher-cache diagnostics because both answer "which build will actually
1334
+ // run?". Strictly read-only: at most ONE `--version` probe, and only when an
1335
+ // override is configured — an unset override runs no command at all, which is
1336
+ // what keeps the "unsupported platform issues zero probes" guarantee intact.
1337
+ // Advisory with respect to the EXIT CODE: an unusable override is reported
1338
+ // loudly and changes nothing about doctor's pass/fail.
1339
+ if (overrides.localCliOverride !== false) {
1340
+ try {
1341
+ const localCliDeps = {
1342
+ env: overrides.localCliOverride?.env ?? deps.env,
1343
+ runCommand: overrides.localCliOverride?.runCommand ??
1344
+ (async (command, args) => deps.runCommand(command, args)),
1345
+ };
1346
+ const diagnostic = await collectLocalCliOverrideDiagnostic(localCliDeps);
1347
+ log(formatLocalCliOverrideDiagnosticReport(diagnostic));
1348
+ }
1349
+ catch {
1350
+ /* local-launcher diagnostics are advisory; never block the doctor report */
1351
+ }
1352
+ }
904
1353
  // Advisory MCP tool-surface capability section (BAPI-641). Strictly read-only:
905
1354
  // one 500 ms GET (or none, under the kill switch). Any timeout, malformed
906
1355
  // response, or unexpected throw degrades to a "fail-open to full surface" line
@@ -937,10 +1386,6 @@ export async function runDoctorCli(argv, overrides = {}) {
937
1386
  // any key. It is collected independently of `DoctorCollectionResult` and, like
938
1387
  // the sections above, never affects the exit code — missing units, malformed
939
1388
  // units, and unresolved credentials are all advisory.
940
- // BAPI-731: set only when isolation is unhealthy AND this host runs an
941
- // executor, so an executor-only requirement never fails an ordinary MCP user's
942
- // doctor run.
943
- let isolationFailed = false;
944
1389
  if (overrides.executorService !== false) {
945
1390
  try {
946
1391
  const injectedFs = deps;
@@ -957,19 +1402,19 @@ export async function runDoctorCli(argv, overrides = {}) {
957
1402
  };
958
1403
  const executorDiagnostics = await collectExecutorServiceDiagnostics(executorDeps);
959
1404
  log(formatExecutorServiceDiagnosticsReport(executorDiagnostics));
960
- // BAPI-731: worker config isolation health, rendered right after the
961
- // executor-service section because it is only REQUIRED when this host
962
- // actually runs an executor. Read-only: it evaluates capability and
963
- // creates nothing (see the assertion in `collectWorkerConfigIsolationDiagnostic`).
964
- const isolationDiagnostic = collectWorkerConfigIsolationDiagnostic({
965
- platform: executorDeps.platform,
966
- env: executorDeps.env,
967
- executorConfigured: executorDiagnostics.status === "collected" && executorDiagnostics.units.length > 0,
1405
+ // BAPI-791: the ONE shared, low-information Claude login advisory for this
1406
+ // doctor report, rendered right after the executor-service section since
1407
+ // that is what it is a prerequisite for. Replaces the retired worker config
1408
+ // isolation health check and Anthropic OAuth lifecycle section — a worker
1409
+ // is no longer isolated into its own configuration directory (BAPI-790) and
1410
+ // no Anthropic credential is stored or diagnosed by Bridge any more
1411
+ // (BAPI-791); this line reports only whether the host's own
1412
+ // `~/.claude.json` carries a login marker. It never blocks the doctor run.
1413
+ const loginResult = await detectClaudeLogin({
1414
+ homedir: executorDeps.homedir,
1415
+ readFile: executorDeps.readFile,
968
1416
  });
969
- log(formatWorkerConfigIsolationDiagnosticReport(isolationDiagnostic));
970
- if (isolationDiagnostic.status === "unhealthy" && isolationDiagnostic.required) {
971
- isolationFailed = true;
972
- }
1417
+ log(`\n${formatClaudeLoginAdvisory(loginResult)}`);
973
1418
  }
974
1419
  catch {
975
1420
  // Any unexpected failure still renders a sanitized advisory SKIP line.
@@ -1004,16 +1449,62 @@ export async function runDoctorCli(argv, overrides = {}) {
1004
1449
  // Any unexpected failure still renders a sanitized advisory line rather
1005
1450
  // than dropping the section or leaking exception text.
1006
1451
  log(formatPlaneCredentialDiagnosticReport({
1007
- anthropicKeyPresent: false,
1008
1452
  repo: null,
1009
1453
  target: null,
1010
1454
  bridgeCredentialResolved: false,
1011
1455
  }));
1012
1456
  }
1013
1457
  }
1458
+ // Advisory conduct-epic section (BAPI-803). Read-only, and — like every
1459
+ // section above — it cannot change the exit code: `conduct-epic init` enforces
1460
+ // these same invariants at the point where they actually block, and someone who
1461
+ // never conducts an epic should not see a failing doctor because of it.
1462
+ //
1463
+ // Skipped entirely on an unsupported platform. This section is the only
1464
+ // advisory one that runs COMMAND probes (`gh`, Worktrunk), and doctor's
1465
+ // standing contract is that an unsupported platform probes nothing at all —
1466
+ // reporting on tooling for a `conduct-epic` that cannot run there would buy
1467
+ // nothing and break that contract.
1468
+ if (overrides.conductEpic !== false && collection.ok) {
1469
+ try {
1470
+ const injectedFs = deps;
1471
+ const conductDeps = {
1472
+ env: overrides.conductEpic?.env ?? deps.env,
1473
+ cwd: overrides.conductEpic?.cwd ?? deps.cwd,
1474
+ platform: overrides.conductEpic?.platform ?? deps.platform,
1475
+ homedir: overrides.conductEpic?.homedir ?? injectedFs.homedir ?? os.homedir,
1476
+ readFile: overrides.conductEpic?.readFile ??
1477
+ injectedFs.readFile ??
1478
+ ((p) => readFile(p, "utf-8")),
1479
+ stat: overrides.conductEpic?.stat ?? injectedFs.stat ?? ((p) => stat(p)),
1480
+ readdir: overrides.conductEpic?.readdir ?? ((p) => readdir(p)),
1481
+ runCommand: overrides.conductEpic?.runCommand ?? deps.runCommand,
1482
+ resolveAccess: overrides.conductEpic?.resolveAccess,
1483
+ getIndexBranch: overrides.conductEpic?.getIndexBranch,
1484
+ isProcessAlive: overrides.conductEpic?.isProcessAlive,
1485
+ };
1486
+ log(formatConductEpicDiagnosticReport(await collectConductEpicDiagnostic(conductDeps)));
1487
+ }
1488
+ catch {
1489
+ // Any unexpected failure still renders a sanitized advisory section rather
1490
+ // than dropping it or leaking exception text.
1491
+ log(formatConductEpicDiagnosticReport({
1492
+ ghPresent: false,
1493
+ ghAuthenticated: false,
1494
+ worktrunkBinary: resolveWorktrunkBinary(deps.platform, deps.env),
1495
+ worktrunkAvailable: false,
1496
+ repo: null,
1497
+ bridgeCredentialResolved: false,
1498
+ stateDirectory: null,
1499
+ stateDirectoryReadable: false,
1500
+ checkpoints: [],
1501
+ locks: [],
1502
+ indexOverrideBranch: null,
1503
+ indexOverrideChecked: false,
1504
+ }));
1505
+ }
1506
+ }
1014
1507
  if (!collection.ok)
1015
1508
  return 1;
1016
- if (isolationFailed)
1017
- return 1;
1018
1509
  return collection.results.some((r) => !r.found) ? 1 : 0;
1019
1510
  }