@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
@@ -9,8 +9,8 @@
9
9
  * unsafe to merge is left untouched with a warning. Missing hooks are a degraded
10
10
  * OPTIONAL capability — never a fatal error.
11
11
  */
12
- import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync, } from "node:fs";
13
- import { dirname, isAbsolute, resolve } from "node:path";
12
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync, } from "node:fs";
13
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { runGitCommand } from "./git-inspection.js";
16
16
  /** Markers delimiting the conductor-managed block inside a hook file. */
@@ -36,10 +36,120 @@ export function resolveGitHooksDirectory(deps = {}) {
36
36
  const absoluteCommonDir = isAbsolute(commonDir) ? commonDir : resolve(cwd, commonDir);
37
37
  return { is_worktree: true, hooks_dir: resolve(absoluteCommonDir, "hooks") };
38
38
  }
39
- /** Best-effort default path to the compiled conductor bin (`build/conductor-bin.js`). */
40
- function defaultConductorBin() {
41
- // This file compiles to build/conductor/git-hooks.js; the bin is build/conductor-bin.js.
42
- return resolve(dirname(fileURLToPath(import.meta.url)), "..", "conductor-bin.js");
39
+ /** The file name every managed hook body must invoke. */
40
+ export const CONDUCTOR_BIN_FILENAME = "conductor-bin.js";
41
+ /**
42
+ * The sanitized failure reason. Names the layout categories that were searched
43
+ * so the operator can act, and nothing else — no probed path, no caught error
44
+ * text, no credential-bearing string can reach it.
45
+ */
46
+ export const CONDUCTOR_HOOK_BIN_UNRESOLVED_REASON = `no usable ${CONDUCTOR_BIN_FILENAME} was found for the executing artifact ` +
47
+ "(searched: the sibling bundle layout, the parent TypeScript-emit layout, and " +
48
+ `the invocation target when it is named ${CONDUCTOR_BIN_FILENAME}). ` +
49
+ "Reinstall or rebuild the package so a conductor binary ships beside the executing module.";
50
+ /**
51
+ * Absolute, canonical path of the `conductor-bin.js` belonging to the artifact
52
+ * that is currently executing (BAPI-772, the BAPI-768 pattern applied to hooks).
53
+ *
54
+ * ## Why this cannot be path arithmetic
55
+ *
56
+ * The old implementation hard-coded ONE layout — `dirname(import.meta.url)/../
57
+ * conductor-bin.js`, the tsc emit — and returned it unverified. In the shipped
58
+ * esbuild bundle this module IS `build/conductor-bin.js`, so the directory hop
59
+ * landed on `<pkg>/conductor-bin.js`, a path that has never existed. Every hook
60
+ * installed from the published package embedded that dead path, and because hook
61
+ * bodies discard output and end in `|| true`, each commit ENOENTed in silence
62
+ * while `conductor doctor` still called the hooks healthy.
63
+ *
64
+ * So this resolver PROBES. Candidates are drawn only from the executing module's
65
+ * own URL and Node's actual invocation target — never `process.cwd()` or an
66
+ * assumed package root, which could point a hook at a *different build* than the
67
+ * one the operator installed from. Every candidate is canonicalized through
68
+ * `realpath` (which also collapses `..` segments and rejects a dangling symlink)
69
+ * and must stat as a regular file before it can be returned.
70
+ */
71
+ export function resolveConductorHookBin(overrides = {}) {
72
+ const deps = {
73
+ moduleUrl: overrides.moduleUrl ?? import.meta.url,
74
+ argv1: overrides.argv1 ?? process.argv[1],
75
+ realpath: overrides.realpath ?? defaultRealpath,
76
+ isFile: overrides.isFile ?? defaultIsFile,
77
+ };
78
+ const moduleFile = moduleFilePath(deps.moduleUrl);
79
+ const candidates = [];
80
+ if (moduleFile !== null) {
81
+ const dir = dirname(moduleFile);
82
+ // Bundled layout: the bin sits beside the executing module (and in the
83
+ // single-file bundle it IS the executing module).
84
+ candidates.push(join(dir, CONDUCTOR_BIN_FILENAME));
85
+ // tsc layout: `build/conductor/git-hooks.js` → `build/conductor-bin.js`.
86
+ candidates.push(join(dir, "..", CONDUCTOR_BIN_FILENAME));
87
+ }
88
+ // Last resort, and only ever a validated one: a packaged install launches
89
+ // through a `node_modules/.bin` symlink. Accepted only when the invocation
90
+ // target is exactly the conductor bin — `index.js` or `conductor-bin.js.bak`
91
+ // must never become a hook target.
92
+ if (typeof deps.argv1 === "string" && deps.argv1.length > 0) {
93
+ if (basename(deps.argv1) === CONDUCTOR_BIN_FILENAME)
94
+ candidates.push(deps.argv1);
95
+ }
96
+ const seen = new Set();
97
+ for (const candidate of candidates) {
98
+ // Canonicalize BEFORE probing so duplicates that differ only by symlink or
99
+ // `..` segment collapse, and a written hook can only ever embed a canonical
100
+ // path to an existing file.
101
+ //
102
+ // The whole loop is exception-contained: this resolver's contract is that it
103
+ // returns a discriminated result and NEVER throws, and that has to hold for
104
+ // an injected boundary too — otherwise a filesystem error (whose message can
105
+ // carry a home-dir path or credential) escapes as a raw exception and defeats
106
+ // the sanitized `reason` this function exists to produce.
107
+ let canonical;
108
+ try {
109
+ canonical = deps.realpath(candidate);
110
+ }
111
+ catch {
112
+ continue;
113
+ }
114
+ if (canonical === null)
115
+ continue;
116
+ if (seen.has(canonical))
117
+ continue;
118
+ seen.add(canonical);
119
+ try {
120
+ if (deps.isFile(canonical))
121
+ return { ok: true, path: canonical };
122
+ }
123
+ catch {
124
+ continue;
125
+ }
126
+ }
127
+ return { ok: false, reason: CONDUCTOR_HOOK_BIN_UNRESOLVED_REASON };
128
+ }
129
+ /** `file://` URL → filesystem path, or `null` when the URL is unusable. */
130
+ function moduleFilePath(moduleUrl) {
131
+ try {
132
+ return fileURLToPath(moduleUrl);
133
+ }
134
+ catch {
135
+ return null;
136
+ }
137
+ }
138
+ function defaultRealpath(filePath) {
139
+ try {
140
+ return realpathSync(filePath);
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ }
146
+ function defaultIsFile(filePath) {
147
+ try {
148
+ return statSync(filePath).isFile();
149
+ }
150
+ catch {
151
+ return false;
152
+ }
43
153
  }
44
154
  function wrapManagedBlock(body) {
45
155
  return [BRIDGE_CONDUCTOR_HOOK_START, body, BRIDGE_CONDUCTOR_HOOK_END].join("\n");
@@ -107,27 +217,56 @@ export function mergeManagedHookSnippet(existing, managedBlock) {
107
217
  const content = `${existing}${separator}${managedBlock}\n`;
108
218
  return { ok: true, content, action: "appended" };
109
219
  }
220
+ /** Stable discriminator for an install that never touched the filesystem. */
221
+ export const CONDUCTOR_BIN_UNRESOLVED_ERROR = "CONDUCTOR_BIN_UNRESOLVED";
110
222
  function snippetForHook(name, conductorBin) {
111
223
  return name === "post-commit"
112
224
  ? buildPostCommitHookSnippet(conductorBin)
113
225
  : buildReferenceTransactionHookSnippet(conductorBin);
114
226
  }
227
+ /**
228
+ * Re-validate a resolution before it can reach a hook body. A success produced
229
+ * by an injected {@link GitHooksDeps.resolveBin} seam goes through the same
230
+ * realpath + regular-file rules the production resolver applies, so the seam can
231
+ * substitute a fixture binary but can never bypass validation.
232
+ */
233
+ function validateResolvedBin(resolution) {
234
+ if (!resolution.ok)
235
+ return resolution;
236
+ const canonical = defaultRealpath(resolution.path);
237
+ if (canonical === null || !defaultIsFile(canonical)) {
238
+ return { ok: false, reason: CONDUCTOR_HOOK_BIN_UNRESOLVED_REASON };
239
+ }
240
+ return { ok: true, path: canonical };
241
+ }
115
242
  /**
116
243
  * Install or update the managed `post-commit` and `reference-transaction` hooks.
117
244
  * Creates the hooks directory when needed and marks hook files executable on
118
245
  * POSIX. A non-worktree directory is reported as a degraded optional capability
119
246
  * (warning), never a fatal error.
247
+ *
248
+ * BAPI-772: the conductor binary is resolved and validated BEFORE any filesystem
249
+ * mutation. When it cannot be resolved this returns the failure variant and
250
+ * touches nothing — a dead hook is now structurally unwritable rather than
251
+ * silently installed. The generated runtime bodies are unchanged (detached,
252
+ * output discarded, `|| true`): only installation-time resolution is fail-loud.
120
253
  */
121
254
  export function installConductorGitHooks(deps = {}) {
122
255
  const platform = deps.platform ?? process.platform;
123
- const conductorBin = deps.conductorBin ?? defaultConductorBin();
256
+ const resolution = validateResolvedBin(deps.resolveBin ? deps.resolveBin() : resolveConductorHookBin());
257
+ if (!resolution.ok) {
258
+ return { ok: false, error: CONDUCTOR_BIN_UNRESOLVED_ERROR, reason: resolution.reason };
259
+ }
260
+ const conductorBin = resolution.path;
124
261
  const { is_worktree, hooks_dir } = resolveGitHooksDirectory(deps);
125
262
  if (!is_worktree || hooks_dir === null) {
126
263
  return {
264
+ ok: true,
127
265
  is_worktree: false,
128
266
  hooks_dir: null,
129
267
  installed: [],
130
268
  warnings: ["not a git worktree; git hooks were not installed (degraded optional capability)"],
269
+ conductor_bin: conductorBin,
131
270
  };
132
271
  }
133
272
  const warnings = [];
@@ -166,7 +305,35 @@ export function installConductorGitHooks(deps = {}) {
166
305
  }
167
306
  installed.push({ name, path: hookPath, action: merge.action });
168
307
  }
169
- return { is_worktree: true, hooks_dir, installed, warnings };
308
+ return { ok: true, is_worktree: true, hooks_dir, installed, warnings, conductor_bin: conductorBin };
309
+ }
310
+ /**
311
+ * Extract the JSON-quoted binary argument from the managed block's generated
312
+ * `node "<path>" git-hook ...` command (BAPI-772).
313
+ *
314
+ * Parsing is deliberately narrow rather than a general shell tokenization: only
315
+ * the exact command shape this module writes is recognized, and the quoted
316
+ * argument is read back with `JSON.parse` — the same encoding
317
+ * {@link buildPostCommitHookSnippet} used to write it, so a path containing
318
+ * spaces or quotes round-trips exactly. A hook that was hand-edited into an
319
+ * unrecognized shape yields `null` rather than a throw or a guess.
320
+ */
321
+ export function extractManagedHookBinPath(content) {
322
+ const startIdx = content.indexOf(BRIDGE_CONDUCTOR_HOOK_START);
323
+ const endIdx = content.indexOf(BRIDGE_CONDUCTOR_HOOK_END);
324
+ if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx)
325
+ return null;
326
+ const block = content.slice(startIdx, endIdx);
327
+ const match = /\bnode\s+("(?:[^"\\]|\\.)*")\s+git-hook\b/.exec(block);
328
+ if (match === null)
329
+ return null;
330
+ try {
331
+ const parsed = JSON.parse(match[1]);
332
+ return typeof parsed === "string" && parsed.length > 0 ? parsed : null;
333
+ }
334
+ catch {
335
+ return null;
336
+ }
170
337
  }
171
338
  /**
172
339
  * Inspect the managed git hooks WITHOUT modifying anything. Reports worktree
@@ -193,6 +360,8 @@ export function inspectConductorGitHooks(deps = {}) {
193
360
  const exists = existsSync(hookPath);
194
361
  let executable = false;
195
362
  let managedPresent = false;
363
+ let embeddedBinPath = null;
364
+ let embeddedBinIsFile = false;
196
365
  if (exists) {
197
366
  try {
198
367
  const mode = statSync(hookPath).mode;
@@ -202,7 +371,24 @@ export function inspectConductorGitHooks(deps = {}) {
202
371
  /* unreadable stat — leave executable false */
203
372
  }
204
373
  try {
205
- managedPresent = readFileSync(hookPath, "utf-8").includes(BRIDGE_CONDUCTOR_HOOK_START);
374
+ const content = readFileSync(hookPath, "utf-8");
375
+ managedPresent = content.includes(BRIDGE_CONDUCTOR_HOOK_START);
376
+ if (managedPresent) {
377
+ // BAPI-772: a managed block is not enough — the block written by an
378
+ // older build can point at a binary that never existed. Stat the
379
+ // embedded target READ-ONLY so a dead hook is visible instead of
380
+ // reported healthy. Nothing here rewrites or canonicalizes the hook.
381
+ embeddedBinPath = extractManagedHookBinPath(content);
382
+ embeddedBinIsFile = embeddedBinPath !== null && defaultIsFile(embeddedBinPath);
383
+ if (embeddedBinPath === null) {
384
+ warnings.push(`${name} hook has a managed block with no recognizable conductor command; ` +
385
+ "reinstall with `conductor install-git-hooks`");
386
+ }
387
+ else if (!embeddedBinIsFile) {
388
+ warnings.push(`${name} hook points at a missing or invalid conductor binary (${embeddedBinPath}); ` +
389
+ "the hook is dead — reinstall with `conductor install-git-hooks`");
390
+ }
391
+ }
206
392
  }
207
393
  catch {
208
394
  /* unreadable — leave managedPresent false */
@@ -211,8 +397,16 @@ export function inspectConductorGitHooks(deps = {}) {
211
397
  else {
212
398
  warnings.push(`${name} hook not installed (degraded optional capability)`);
213
399
  }
214
- hooks.push({ name, path: hookPath, exists, executable, managed_block_present: managedPresent });
400
+ hooks.push({
401
+ name,
402
+ path: hookPath,
403
+ exists,
404
+ executable,
405
+ managed_block_present: managedPresent,
406
+ embedded_bin_path: embeddedBinPath,
407
+ embedded_bin_is_file: embeddedBinIsFile,
408
+ });
215
409
  }
216
- const degraded = hooks.some((h) => !h.exists || !h.managed_block_present);
410
+ const degraded = hooks.some((h) => !h.exists || !h.managed_block_present || !h.embedded_bin_is_file);
217
411
  return { is_worktree: true, hooks_dir, hooks, warnings, degraded };
218
412
  }
@@ -17,13 +17,25 @@
17
17
  * additive: a change to any legacy builder cannot silently alter what the unified
18
18
  * doctor reports about it.
19
19
  *
20
+ * BAPI-775 adds three more named sections for the LOCAL conductor capabilities
21
+ * `install conductor` can now provision: host MCP profile-token presence,
22
+ * managed hook-target usability, and native ledger loadability. All three are
23
+ * DERIVED — from the shared read-only host-config inspector and from the legacy
24
+ * conductor report already composed above — so none of them re-probes anything
25
+ * or can drift from the report embedded beneath it. All three are non-fatal, so
26
+ * a missing local capability can never change
27
+ * {@link conductorInstallDoctorExitCode}.
28
+ *
20
29
  * STRICTLY READ-ONLY. No POST, no filesystem write, no install-state write, no
21
30
  * workflow scaffold, no schema migration, no hook installation. It is invoked
22
31
  * from a write-capable installer, so that boundary is pinned by tests.
23
32
  */
24
33
  import { readdir as fsReaddir, readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
25
34
  import os from "node:os";
26
- import { buildConductorDoctorReport, formatConductorDoctorReport, } from "./doctor.js";
35
+ import { buildConductorDoctorReport, describeNativeLedgerAvailability, formatConductorDoctorReport, } from "./doctor.js";
36
+ import { MANAGED_HOOK_NAMES } from "./git-hooks.js";
37
+ import { inspectBridgeApiProfileToken, } from "../mcp-host-config.js";
38
+ import { MCP_PACKAGE_NAME } from "../mcp-identity.js";
27
39
  import { collectExecutorServiceDiagnostics, formatExecutorServiceDiagnosticsReport, } from "../doctor.js";
28
40
  import { collectInstallStatusChecks, formatInstallStatusReport, } from "../install-doctor.js";
29
41
  import { ConductorBridgeApiError, fetchConductorReadiness, } from "./bridge-api-client.js";
@@ -39,6 +51,26 @@ export const CONDUCTOR_OPERATOR_RUNBOOK_POINTER = "docs/claude/epic-conductor-v2
39
51
  */
40
52
  export const EXECUTOR_PROVISIONING_GUIDANCE = "`install conductor` writes the unit and starts it, each behind its own consent; " +
41
53
  "readiness still comes only from a live server observation";
54
+ /**
55
+ * The `BRIDGE_MCP_PROFILE` token that makes the 8 conductor MCP tools visible.
56
+ *
57
+ * The tools are profile-gated, never core, and this is the only token that
58
+ * un-gates them — so the doctor names it explicitly rather than describing the
59
+ * gap abstractly (BAPI-775).
60
+ */
61
+ export const CONDUCTOR_PROFILE_TOKEN = "conductor";
62
+ /** Remediation for a host MCP config that carries no Bridge entry at all. */
63
+ export const CONDUCTOR_PROFILE_TOKEN_NO_ENTRY_REMEDIATION = "no supported host MCP config carries a Bridge entry — run " +
64
+ `\`npx ${MCP_PACKAGE_NAME} install\` to register one, then re-run \`install conductor\`.`;
65
+ /** Remediation for an entry that exists but lacks the conductor token. */
66
+ export const CONDUCTOR_PROFILE_TOKEN_REMEDIATION = "re-run `install conductor` and accept the tool-visibility consent; the conductor " +
67
+ "tools become visible after the MCP client is restarted.";
68
+ /** Remediation surfaced whenever the managed conductor hooks are not usable. */
69
+ export const CONDUCTOR_HOOK_TARGET_REMEDIATION = "re-run `install conductor` and accept the local-observability consent, or run " +
70
+ "`conductor install-git-hooks` directly.";
71
+ /** Remediation surfaced when the ledger's native binding does not load. */
72
+ export const CONDUCTOR_LEDGER_LOADABILITY_REMEDIATION = "`better-sqlite3` is an optionalDependency npm silently skips on a build failure — " +
73
+ "reinstall it for this Node runtime to restore local conductor observability.";
42
74
  /** Remediation surfaced whenever no healthy generated service unit is detected. */
43
75
  export const EXECUTOR_INSTALL_SERVICE_REMEDIATION = "generate a persistent unit with `install conductor --executor-id <id>` or " +
44
76
  "`executor install-service` (or run the executor by hand) — " +
@@ -382,6 +414,183 @@ function executorObservationSection(readiness) {
382
414
  };
383
415
  }
384
416
  // ---------------------------------------------------------------------------
417
+ // Local conductor capability sections (BAPI-775)
418
+ //
419
+ // Three INDEPENDENT read-only checks — host profile token, managed hook targets,
420
+ // native ledger loadability. All three are non-fatal by construction: a missing
421
+ // local conductor capability is a repairable gap, and letting one flip
422
+ // `conductorInstallDoctorExitCode` to 1 would make every ordinary install fail
423
+ // on a capability it never asked for.
424
+ // ---------------------------------------------------------------------------
425
+ /**
426
+ * Whether this host looks like a conductor context.
427
+ *
428
+ * Composed from the two facts already collected: the resolved MCP-profile
429
+ * inspection (`BAPI_CONDUCTOR_ENABLED` or a registered epic-tick schedule) and
430
+ * the read-only host token inspection. Outside such a context a missing local
431
+ * capability is ADVISORY — reporting it as a gap would train operators of
432
+ * ordinary repositories to ignore this whole part of the report.
433
+ */
434
+ function detectConductorContext(legacyConductor, profile) {
435
+ // Read defensively: an INJECTED report sentinel (and the installer's own
436
+ // degraded-doctor fallback) may populate only the fields a test cares about,
437
+ // and a doctor that throws on a partially-built report would abort the run it
438
+ // exists to diagnose. Same posture as the defensive legacy formatters below.
439
+ return (legacyConductor?.mcp_profile?.conductor_context_detected === true ||
440
+ profile?.tokenPresent === true);
441
+ }
442
+ /**
443
+ * Host MCP profile-token presence.
444
+ *
445
+ * Reports CONFIG IDENTIFIERS AND BOOLEANS ONLY — never an entry, an `env`
446
+ * object, a command line, an API key, or an unrelated profile token — and names
447
+ * only the three configs the merge path can write, so nothing here can imply
448
+ * that Codex or Copilot configuration was inspected or changed.
449
+ */
450
+ function conductorProfileTokenSection(profile, conductorContext) {
451
+ const base = { id: "conductor-profile-token", label: "MCP conductor profile token" };
452
+ if (profile === null) {
453
+ return {
454
+ ...base,
455
+ status: "degraded",
456
+ detail: "the host MCP profile token could not be inspected",
457
+ remediation: CONDUCTOR_PROFILE_TOKEN_REMEDIATION,
458
+ };
459
+ }
460
+ const carrying = profile.configs
461
+ .filter((c) => c.state === "token-present")
462
+ .map((c) => c.path);
463
+ const scope = "inspected .mcp.json, .vscode/mcp.json, .cursor/mcp.json only " +
464
+ "(Codex and Copilot host configuration is not inspected)";
465
+ if (carrying.length > 0) {
466
+ return {
467
+ ...base,
468
+ status: "ok",
469
+ detail: `\`${profile.token}\` present in ${carrying.join(", ")}; ${scope}`,
470
+ };
471
+ }
472
+ // "No entry anywhere" and "an entry without the token" are different findings
473
+ // with different next steps, so they never collapse into one remediation.
474
+ const detail = profile.bridgeEntryPresent
475
+ ? `no supported host config carries the \`${profile.token}\` token; ${scope}`
476
+ : `no supported host config carries a Bridge entry; ${scope}`;
477
+ const remediation = profile.bridgeEntryPresent
478
+ ? CONDUCTOR_PROFILE_TOKEN_REMEDIATION
479
+ : CONDUCTOR_PROFILE_TOKEN_NO_ENTRY_REMEDIATION;
480
+ return conductorContext
481
+ ? { ...base, status: "degraded", detail, remediation }
482
+ : { ...base, status: "ok", detail: `advisory — ${detail}`, remediation };
483
+ }
484
+ /**
485
+ * Managed conductor hook targets, DERIVED from `legacyConductor.git_hooks`.
486
+ *
487
+ * Derived, never re-probed: a second parse of the same hook files is a second
488
+ * chance to disagree with the report embedded right below this section. Healthy
489
+ * requires each managed hook to exist, carry the managed block, and point at an
490
+ * embedded binary that stats as a regular file — a hook whose target vanished is
491
+ * a dead hook, and reporting it as installed is the exact failure BAPI-772 made
492
+ * structurally impossible at INSTALL time but which can still arise later.
493
+ *
494
+ * Renders hook NAME and boolean state only. The embedded absolute binary path is
495
+ * read for the file check and never copied into this section.
496
+ */
497
+ function conductorHookTargetsSection(legacyConductor, conductorContext) {
498
+ const base = { id: "conductor-hook-targets", label: "Conductor hook targets" };
499
+ // Nullish, not `=== null`: an injected builder may resolve `undefined`, and a
500
+ // doctor that throws on it would abort the run it exists to diagnose.
501
+ if (legacyConductor === null || legacyConductor === undefined) {
502
+ return {
503
+ ...base,
504
+ status: "degraded",
505
+ detail: "hook targets could not be derived (the conductor doctor was not collected)",
506
+ remediation: CONDUCTOR_HOOK_TARGET_REMEDIATION,
507
+ };
508
+ }
509
+ // Defensive for the same reason as {@link detectConductorContext}: an injected
510
+ // sentinel need not carry every legacy field.
511
+ const hooks = legacyConductor.git_hooks;
512
+ if (hooks === undefined) {
513
+ return {
514
+ ...base,
515
+ status: "degraded",
516
+ detail: "hook targets could not be derived (the conductor doctor reported no hook state)",
517
+ remediation: CONDUCTOR_HOOK_TARGET_REMEDIATION,
518
+ };
519
+ }
520
+ if (!hooks.is_worktree) {
521
+ return {
522
+ ...base,
523
+ status: "ok",
524
+ detail: "advisory — not a git worktree, so no managed conductor hooks apply",
525
+ };
526
+ }
527
+ const usable = (name) => {
528
+ const hook = (hooks.hooks ?? []).find((h) => h.name === name);
529
+ return (hook !== undefined &&
530
+ hook.exists &&
531
+ hook.managed_block_present &&
532
+ hook.embedded_bin_is_file);
533
+ };
534
+ const rendered = MANAGED_HOOK_NAMES.map((name) => `${name}=${usable(name)}`).join(", ");
535
+ const allUsable = MANAGED_HOOK_NAMES.every((name) => usable(name));
536
+ if (allUsable) {
537
+ return { ...base, status: "ok", detail: `managed hooks usable: ${rendered}` };
538
+ }
539
+ const detail = `managed hooks usable: ${rendered}`;
540
+ return conductorContext
541
+ ? { ...base, status: "degraded", detail, remediation: CONDUCTOR_HOOK_TARGET_REMEDIATION }
542
+ : {
543
+ ...base,
544
+ status: "ok",
545
+ detail: `advisory — ${detail}`,
546
+ remediation: CONDUCTOR_HOOK_TARGET_REMEDIATION,
547
+ };
548
+ }
549
+ /**
550
+ * Native ledger loadability, DERIVED from `legacyConductor.native_ledger`.
551
+ *
552
+ * No parallel `better-sqlite3` check: the mapping from an inspection to
553
+ * available/degraded lives in one place ({@link describeNativeLedgerAvailability}),
554
+ * so this section and the conductor doctor can never disagree about whether the
555
+ * binding loads. A degraded result carries the module name, the ABI, and the
556
+ * sanitized failure kind, because the common cause is npm silently skipping an
557
+ * optionalDependency build and that is undiagnosable without the ABI.
558
+ */
559
+ function conductorLedgerLoadabilitySection(legacyConductor) {
560
+ const base = {
561
+ id: "conductor-ledger-loadability",
562
+ label: "Conductor ledger loadability",
563
+ };
564
+ if (legacyConductor === null || legacyConductor === undefined) {
565
+ return {
566
+ ...base,
567
+ status: "degraded",
568
+ detail: "ledger loadability could not be derived (the conductor doctor was not collected)",
569
+ remediation: CONDUCTOR_LEDGER_LOADABILITY_REMEDIATION,
570
+ };
571
+ }
572
+ const inspection = legacyConductor.native_ledger;
573
+ if (inspection === undefined) {
574
+ return {
575
+ ...base,
576
+ status: "degraded",
577
+ detail: "ledger loadability could not be derived (the conductor doctor reported no native-ledger state)",
578
+ remediation: CONDUCTOR_LEDGER_LOADABILITY_REMEDIATION,
579
+ };
580
+ }
581
+ const availability = describeNativeLedgerAvailability(inspection);
582
+ if (availability.status === "available") {
583
+ return { ...base, status: "ok", detail: "ledger available — the native binding loads" };
584
+ }
585
+ return {
586
+ ...base,
587
+ status: "degraded",
588
+ detail: `ledger degraded — ${availability.module} did not load ` +
589
+ `(NODE_MODULE_VERSION ${availability.nodeModulesAbi}, failure: ${availability.failureKind})`,
590
+ remediation: CONDUCTOR_LEDGER_LOADABILITY_REMEDIATION,
591
+ };
592
+ }
593
+ // ---------------------------------------------------------------------------
385
594
  // Runner
386
595
  // ---------------------------------------------------------------------------
387
596
  async function inspectWorkflowPresence(readWorkflowFile) {
@@ -539,6 +748,26 @@ export async function runConductorInstallDoctor(deps) {
539
748
  // --- Server observation: the authoritative executor fact, last so it reads
540
749
  // as the conclusion of the three-part executor story.
541
750
  sections.push(executorObservationSection(readiness));
751
+ // --- Local conductor capabilities (BAPI-775): three independent read-only
752
+ // checks, appended after the executor story so they read as their own group.
753
+ // Every one of them is non-fatal, so none can change the doctor's exit code.
754
+ const inspectProfile = deps.inspectProfileToken ??
755
+ ((projectRoot, token) => inspectBridgeApiProfileToken(projectRoot, token, {
756
+ readFile: (filePath) => fsReadFile(filePath, "utf-8"),
757
+ }));
758
+ let profileToken = null;
759
+ try {
760
+ profileToken = await inspectProfile(deps.projectRoot ?? process.cwd(), CONDUCTOR_PROFILE_TOKEN);
761
+ }
762
+ catch {
763
+ // The shared inspector never throws for the ordinary absent/malformed
764
+ // cases; this guards an injected fake. A null inspection reports the
765
+ // collection gap rather than losing the section.
766
+ }
767
+ const conductorContext = detectConductorContext(legacyConductor, profileToken);
768
+ sections.push(conductorProfileTokenSection(profileToken, conductorContext));
769
+ sections.push(conductorHookTargetsSection(legacyConductor, conductorContext));
770
+ sections.push(conductorLedgerLoadabilitySection(legacyConductor));
542
771
  return {
543
772
  legacyConductor,
544
773
  legacyExecutorPreflight,