@bridge_gpt/mcp-server 0.2.52 → 0.2.53

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.
@@ -769,6 +769,34 @@ export async function fetchEpicRunState(access, epicKey, fetchImpl = globalThis.
769
769
  const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
770
770
  return parsed;
771
771
  }
772
+ /**
773
+ * Fetch the read-only ExplainRun / ExplainTicket projection (BAPI-1028).
774
+ *
775
+ * One function, two scopes, because they are two selectors over ONE projection:
776
+ * without `ticketKey` it reads the whole run, with it the single ticket. Both
777
+ * responses carry the same `schema_version` and the ticket response's `ticket`
778
+ * is byte-identical to that ticket's entry in the run response.
779
+ *
780
+ * Read-only end to end: the server route composes persisted rows, writes
781
+ * nothing, enqueues nothing, and calls no provider. Non-2xx responses travel
782
+ * through the shared timeout/sanitization helper as a `ConductorBridgeApiError`,
783
+ * so no response body ever reaches a caller.
784
+ */
785
+ export async function fetchExplainRun(access, epicRunId, ticketKey, fetchImpl = globalThis.fetch) {
786
+ requireNonEmptyString(epicRunId);
787
+ let apiPath = `${epicRunApiPath(epicRunId)}/explain`;
788
+ if (ticketKey !== undefined) {
789
+ requireNonEmptyString(ticketKey);
790
+ // Encoded as ONE path segment: a ticket key carrying a slash would
791
+ // otherwise silently reshape the URL into a different endpoint.
792
+ requireNoSlashPathSegment(ticketKey);
793
+ apiPath = `${epicRunApiPath(epicRunId)}/tickets/${encodeURIComponent(ticketKey)}/explain`;
794
+ }
795
+ const url = buildConductorJiraUrl(access.baseUrl, apiPath, {
796
+ repo_name: access.repoName,
797
+ });
798
+ return fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
799
+ }
772
800
  // ---------------------------------------------------------------------------
773
801
  // Executor wind-down: read-only completion-state projection (BAPI-1010)
774
802
  // ---------------------------------------------------------------------------
@@ -1886,6 +1914,39 @@ export function parseConductorReadinessResponse(body) {
1886
1914
  reconciler_stale_after_seconds: requireInt(thr, "reconciler_stale_after_seconds"),
1887
1915
  executor_stale_after_seconds: requireInt(thr, "executor_stale_after_seconds"),
1888
1916
  },
1917
+ review_workflow: parseReviewWorkflow(root),
1918
+ conductor_ci_workflow: parseConductorCiWorkflow(root),
1919
+ };
1920
+ }
1921
+ /**
1922
+ * Parse the optional `review_workflow` block.
1923
+ *
1924
+ * Absent (or null) yields `null` — an older server, not a finding. A PRESENT
1925
+ * block is validated strictly and a malformed one throws the same shape error
1926
+ * as every other field, because a malformed body is untrusted input.
1927
+ */
1928
+ function parseReviewWorkflow(body) {
1929
+ const raw = body.review_workflow;
1930
+ if (raw === undefined || raw === null)
1931
+ return null;
1932
+ const o = requireObject(raw);
1933
+ return {
1934
+ probe_succeeded: requireBool(o, "probe_succeeded"),
1935
+ workflow_present: requireBool(o, "workflow_present"),
1936
+ emits_run_id: requireBool(o, "emits_run_id"),
1937
+ emits_reviewed_sha: requireBool(o, "emits_reviewed_sha"),
1938
+ };
1939
+ }
1940
+ /** Parse the optional `conductor_ci_workflow` block. Same rule as above. */
1941
+ function parseConductorCiWorkflow(body) {
1942
+ const raw = body.conductor_ci_workflow;
1943
+ if (raw === undefined || raw === null)
1944
+ return null;
1945
+ const o = requireObject(raw);
1946
+ return {
1947
+ probe_succeeded: requireBool(o, "probe_succeeded"),
1948
+ workflow_present: requireBool(o, "workflow_present"),
1949
+ migration_guard_present: requireBool(o, "migration_guard_present"),
1889
1950
  };
1890
1951
  }
1891
1952
  /**
@@ -58,6 +58,10 @@ export function getConductorUsage() {
58
58
  " send-message Enqueue ONE typed supervisor->worker relay message (idempotent)",
59
59
  " check-messages Read + ACK pending relay messages for a worker (no redelivery)",
60
60
  " doctor Read-only health/diagnostics report (ledger + git hooks)",
61
+ " readiness Read-only ADVISORY report of every conductor prerequisite",
62
+ " (install + conductor doctor + plane preflight + server),",
63
+ " each with pass/warn/fail/skip and one named remediation.",
64
+ " Remediations are NOT run automatically; always exits 0.",
61
65
  " purge Delete ALL ledger rows (events, messages, supervisor_projection)",
62
66
  " install-git-hooks Install local, opportunistic, non-blocking git hooks",
63
67
  " git-hook post-commit Run the post-commit producer (invoked by the installed hook)",
@@ -126,6 +130,15 @@ export function getConductorUsage() {
126
130
  " --json Print compact JSON result",
127
131
  " Note: returned messages are ACKNOWLEDGED by this call and are not redelivered.",
128
132
  "",
133
+ "readiness options:",
134
+ " --json Print the versioned structured report as JSON",
135
+ " --no-deny-probe Accepted for parity with `doctor`; inert here — `readiness`",
136
+ " never spawns the deny-enforcement probe, and reports that",
137
+ " check as an explicit skip naming `conductor doctor`",
138
+ " --help Print the readiness usage message",
139
+ " Note: advisory only. It blocks nothing and changes no exit code; `drive-epic`",
140
+ " remains the sole route-selection gate and server-side admission the sole refusal.",
141
+ "",
129
142
  "doctor / purge options:",
130
143
  " --json Print machine-readable JSON",
131
144
  " --no-deny-probe (doctor only) Skip the deny-enforcement preflight — no headless",
@@ -216,6 +229,9 @@ const VALID_COMMANDS = new Set([
216
229
  "send-message",
217
230
  "check-messages",
218
231
  "doctor",
232
+ // BAPI-1055: the consolidated ADVISORY readiness gate. Read-only, always
233
+ // exits 0, and registers no MCP tool — see `readiness-cli.ts`.
234
+ "readiness",
219
235
  "purge",
220
236
  "install-git-hooks",
221
237
  "git-hook",
@@ -1183,6 +1199,13 @@ export async function runConductorCli(argv) {
1183
1199
  return await runCheckMessagesCommand(parsed.argv);
1184
1200
  case "doctor":
1185
1201
  return await runDoctorCommand(parsed.argv);
1202
+ case "readiness": {
1203
+ // Lazily imported, like the recovery verbs: the readiness gate pulls in
1204
+ // the Bridge HTTP client and the plane preflight graph, and a local-only
1205
+ // command (doctor, emit-event) must not pay for that at load time.
1206
+ const { runConductorReadinessCommand } = await import("./readiness-cli.js");
1207
+ return await runConductorReadinessCommand(parsed.argv);
1208
+ }
1186
1209
  case "purge":
1187
1210
  return await runPurgeCommand(parsed.argv);
1188
1211
  case "install-git-hooks":
@@ -16,6 +16,55 @@ import { inspectConductorGitHooks } from "./git-hooks.js";
16
16
  import { resolveMcpShimInvocationForRuntime, resolvePackageRootFromModuleUrl, } from "../mcp-server-invocation.js";
17
17
  import { MCP_PACKAGE_NAME } from "../mcp-identity.js";
18
18
  import { resolveProfiles } from "../mcp-profile.js";
19
+ import { createReadinessCheck, createReadinessCheckSafely, } from "../readiness-check.js";
20
+ /**
21
+ * Remediation for a conductor context whose resolved MCP groups omit `conductor`.
22
+ *
23
+ * Fixed prose: it never interpolates the raw `BRIDGE_MCP_PROFILE` value. The
24
+ * legacy warning does interpolate it (unchanged), but a consolidated report is a
25
+ * wider surface and an operator-set environment variable is not a value this
26
+ * gate needs to echo to name the fix. ADD, never replace — replacing the value
27
+ * would drop the worker's other groups.
28
+ */
29
+ export const MCP_PROFILE_REMEDIATION = "ADD `conductor` to BRIDGE_MCP_PROFILE (comma-separated) — do NOT replace the existing value, " +
30
+ "which would drop the worker's other tool groups. Without it the conductor/event/supervisor " +
31
+ "tools are not registered.";
32
+ /**
33
+ * Remediation for a host with no `gh` on PATH.
34
+ *
35
+ * LOAD-BEARING, not legacy (AC-7). Conductor merges run under the machine's own
36
+ * `gh` auth because the GitHub App token is `contents: read` and structurally
37
+ * cannot merge — see `mcp_server/src/executor/merge-job.ts`. Retiring this check
38
+ * is blocked on epic A2 (App elevation to `contents: write`); until A2 ships,
39
+ * `gh` is a real prerequisite and this stays a named check.
40
+ */
41
+ export const GH_CLI_REMEDIATION = "install the GitHub CLI (`gh`) and put it on PATH; conductor-driven merges run under this " +
42
+ "host's own `gh` auth because the GitHub App token is `contents: read` and cannot merge.";
43
+ /** Remediation for an installed but unauthenticated `gh` (AC-7; see {@link GH_CLI_REMEDIATION}). */
44
+ export const GH_AUTH_REMEDIATION = "run `gh auth login` to grant the conductor merge permission; otherwise local merge emits " +
45
+ "merge.failed/skip rather than merging.";
46
+ /** Detail used when `gh` authentication was never tested because `gh` is absent. */
47
+ export const GH_AUTH_SKIPPED_DETAIL = "not tested — `gh` is not installed, so `gh auth status` was never run";
48
+ /**
49
+ * Remediation for a native ledger whose binding does not load.
50
+ *
51
+ * DIAGNOSTIC ONLY (BAPI-526): this never installs, rebuilds, or resolves a
52
+ * package path, and the remediation names the operator action rather than
53
+ * performing it. Retiring this check is blocked on epic B3 (parallel-ledger
54
+ * paring), so it stays a named check until B3 ships (AC-7).
55
+ */
56
+ export const NATIVE_LEDGER_REMEDIATION = "`better-sqlite3` is an optionalDependency npm silently skips on a build failure — reinstall it " +
57
+ "for this Node runtime to restore local conductor observability. Diagnostic only: nothing is " +
58
+ "installed or rebuilt for you.";
59
+ /** Remediation for a worker MCP registration that fell back to the npm channel. */
60
+ export const MCP_REGISTRATION_FORM_REMEDIATION = "build this package so an on-disk entry resolves (`npm run build` in mcp_server), or accept that " +
61
+ "worker MCP registration depends on the published package being reachable.";
62
+ /** Remediation for an unreadable `engines.node`. */
63
+ export const NODE_ENGINE_REMEDIATION = "reinstall the package so its package.json is readable; until then the expected Node version is " +
64
+ "unknown and a Node/ABI skew cannot be diagnosed.";
65
+ /** Remediation for a deny-enforcement result the readiness gate could not establish. */
66
+ export const DENY_ENFORCEMENT_SKIP_REMEDIATION = "run `conductor doctor` (without --no-deny-probe) to execute the owning deny-enforcement " +
67
+ "preflight; `conductor readiness` never spawns that probe itself.";
19
68
  /** Explicit display value for an unset `BRIDGE_MCP_PROFILE`. */
20
69
  export const MCP_PROFILE_UNSET = "(unset)";
21
70
  /**
@@ -105,6 +154,58 @@ export async function inspectNativeLedger(nativeLoad, deps = {}) {
105
154
  warnings.push("Could not read package.json engines.node; expected Node version is unknown.");
106
155
  }
107
156
  const degraded = nativeLoad.degraded || invocation.form !== "absolute-build-path" || engines_node === null;
157
+ // THREE outcomes over the SAME inputs (BAPI-1055), because the aggregate
158
+ // `degraded` above folds three independent facts together and each has its own
159
+ // fix. `degraded` itself is unchanged — this adds structure beside it.
160
+ //
161
+ // PATH-FREE by construction: no branch interpolates `mcp_shim_node`, a server
162
+ // entry path, a package root, or a native error message. Only the module name,
163
+ // the ABI, the sanitized failure kind, and the closed registration form.
164
+ const outcomes = [
165
+ binding_loads
166
+ ? {
167
+ id: "native-ledger",
168
+ label: "Native ledger binding",
169
+ status: "pass",
170
+ detail: `${nativeLoad.module} loads (NODE_MODULE_VERSION ${nativeLoad.node_modules_abi})`,
171
+ }
172
+ : {
173
+ id: "native-ledger",
174
+ label: "Native ledger binding",
175
+ status: "warn",
176
+ detail: `${nativeLoad.module} did not load ` +
177
+ `(NODE_MODULE_VERSION ${nativeLoad.node_modules_abi}, failure: ${failure_kind})`,
178
+ remediation: NATIVE_LEDGER_REMEDIATION,
179
+ },
180
+ invocation.form === "absolute-build-path"
181
+ ? {
182
+ id: "mcp-registration-form",
183
+ label: "Worker MCP registration form",
184
+ status: "pass",
185
+ detail: "resolves an on-disk build entry",
186
+ }
187
+ : {
188
+ id: "mcp-registration-form",
189
+ label: "Worker MCP registration form",
190
+ status: "warn",
191
+ detail: `registration form is \`${invocation.form}\` — the npm-channel fallback`,
192
+ remediation: MCP_REGISTRATION_FORM_REMEDIATION,
193
+ },
194
+ engines_node === null
195
+ ? {
196
+ id: "node-engine",
197
+ label: "Expected Node engine",
198
+ status: "warn",
199
+ detail: "package.json engines.node could not be read",
200
+ remediation: NODE_ENGINE_REMEDIATION,
201
+ }
202
+ : {
203
+ id: "node-engine",
204
+ label: "Expected Node engine",
205
+ status: "pass",
206
+ detail: `expected ${engines_node}; running ${process.version}`,
207
+ },
208
+ ];
108
209
  return {
109
210
  cli_node_version: process.version,
110
211
  engines_node,
@@ -116,6 +217,7 @@ export async function inspectNativeLedger(nativeLoad, deps = {}) {
116
217
  failure_kind,
117
218
  degraded,
118
219
  warnings,
220
+ outcomes,
119
221
  };
120
222
  }
121
223
  /**
@@ -175,6 +277,31 @@ export async function collectConductorNativeLedgerSafe(deps = {}) {
175
277
  failure_kind: NATIVE_LEDGER_UNCOLLECTED_FAILURE_KIND,
176
278
  degraded: true,
177
279
  warnings: ["the native ledger inspection could not be collected on this host"],
280
+ // The collection failure is its own finding, not a load failure: an
281
+ // operator who cannot collect the inspection needs to retry it, not
282
+ // reinstall the binding. Reported at the same granularity as the real
283
+ // inspector so a consolidated report never loses these three checks.
284
+ outcomes: [
285
+ {
286
+ id: "native-ledger",
287
+ label: "Native ledger binding",
288
+ status: "warn",
289
+ detail: "the native ledger inspection could not be collected on this host",
290
+ remediation: NATIVE_LEDGER_REMEDIATION,
291
+ },
292
+ {
293
+ id: "mcp-registration-form",
294
+ label: "Worker MCP registration form",
295
+ status: "skip",
296
+ detail: "not determined — the native ledger inspection could not be collected",
297
+ },
298
+ {
299
+ id: "node-engine",
300
+ label: "Expected Node engine",
301
+ status: "skip",
302
+ detail: "not determined — the native ledger inspection could not be collected",
303
+ },
304
+ ],
178
305
  };
179
306
  }
180
307
  }
@@ -206,7 +333,28 @@ export function inspectMcpProfile(env) {
206
333
  `do NOT replace the value, which would drop the worker's other groups. ` +
207
334
  `Without it the conductor/event/supervisor tools are not registered.`);
208
335
  }
209
- return { raw_profile, active_groups, conductor_context_detected, degraded, warnings };
336
+ // Fixed state labels and the CLOSED active-group vocabulary only (BAPI-1055):
337
+ // `resolveProfiles` yields nothing but recognized group names, so this detail
338
+ // cannot echo an arbitrary operator-set string the way `raw_profile` can.
339
+ const outcomes = [
340
+ degraded
341
+ ? {
342
+ id: "mcp-profile",
343
+ label: "MCP conductor profile",
344
+ status: "warn",
345
+ detail: `a conductor context is active but the resolved groups omit \`conductor\` (active: ${active_groups.join(", ")})`,
346
+ remediation: MCP_PROFILE_REMEDIATION,
347
+ }
348
+ : {
349
+ id: "mcp-profile",
350
+ label: "MCP conductor profile",
351
+ status: "pass",
352
+ detail: conductor_context_detected
353
+ ? `conductor context active; resolved groups: ${active_groups.join(", ")}`
354
+ : `no conductor context detected; resolved groups: ${active_groups.join(", ")}`,
355
+ },
356
+ ];
357
+ return { raw_profile, active_groups, conductor_context_detected, degraded, warnings, outcomes };
210
358
  }
211
359
  /**
212
360
  * Read-only probe of the local-merge (F4) capability. Runs `gh --version` and
@@ -265,7 +413,60 @@ export function inspectLocalMerge(runCommand) {
265
413
  // Capability gap only — local merge is opt-in, so an unauthed host is not a hard
266
414
  // failure unless the operator enabled it. Reported as degraded for visibility.
267
415
  const degraded = !gh_available || !gh_authed;
268
- return { gh_available, gh_authed, degraded, warnings };
416
+ // TWO outcomes, not one (BAPI-1055): installing `gh` and running `gh auth
417
+ // login` are independently actionable, and `degraded` below is deliberately
418
+ // left as the SAME `!gh_available || !gh_authed` conjunction it has always
419
+ // been. When `gh` is absent the auth outcome is `skip`, never `fail` — the
420
+ // authentication was never tested, and claiming it failed would send the
421
+ // operator to `gh auth login` on a host with no `gh`.
422
+ //
423
+ // AC-7 — these two checks are LOAD-BEARING and must not be retired here.
424
+ // Conductor merges run under this machine's own `gh` auth because the GitHub
425
+ // App token is `contents: read` and structurally cannot merge; see
426
+ // `mcp_server/src/executor/merge-job.ts` and the `contents: read` constraint
427
+ // documented in `mcp_server/src/merge-pull-request.ts`. Retirement is owned by
428
+ // epic A2, after the App is elevated to `contents: write`. Nothing in this
429
+ // change removes, hides, or downgrades either check, and no merge behaviour is
430
+ // altered by reporting them.
431
+ const outcomes = [
432
+ gh_available
433
+ ? {
434
+ id: "gh-cli",
435
+ label: "GitHub CLI availability",
436
+ status: "pass",
437
+ detail: "`gh --version` succeeded",
438
+ }
439
+ : {
440
+ id: "gh-cli",
441
+ label: "GitHub CLI availability",
442
+ status: "warn",
443
+ detail: "`gh` is not installed or not on PATH",
444
+ remediation: GH_CLI_REMEDIATION,
445
+ },
446
+ !gh_available
447
+ ? {
448
+ id: "gh-auth",
449
+ label: "GitHub CLI authentication",
450
+ status: "skip",
451
+ detail: GH_AUTH_SKIPPED_DETAIL,
452
+ remediation: GH_CLI_REMEDIATION,
453
+ }
454
+ : gh_authed
455
+ ? {
456
+ id: "gh-auth",
457
+ label: "GitHub CLI authentication",
458
+ status: "pass",
459
+ detail: "`gh auth status` reports an authenticated account",
460
+ }
461
+ : {
462
+ id: "gh-auth",
463
+ label: "GitHub CLI authentication",
464
+ status: "warn",
465
+ detail: "`gh` is installed but `gh auth status` failed",
466
+ remediation: GH_AUTH_REMEDIATION,
467
+ },
468
+ ];
469
+ return { gh_available, gh_authed, degraded, warnings, outcomes };
269
470
  }
270
471
  /**
271
472
  * Build the combined read-only doctor report. Composes the existing ledger
@@ -312,14 +513,18 @@ async function inspectDenyEnforcementSafe(inspect) {
312
513
  }
313
514
  return await run();
314
515
  }
315
- catch (err) {
316
- const msg = err instanceof Error ? err.message : String(err);
516
+ catch {
517
+ // BAPI-1055: the thrown message is NOT interpolated. A spawn failure here
518
+ // routinely carries an absolute executable path or a temp-directory path,
519
+ // and this warning now flows into the consolidated readiness report as well
520
+ // as the doctor's own output. The actionable fact — enforcement could not be
521
+ // established, so claiming must refuse — is fixed prose.
317
522
  return {
318
523
  enforced: false,
319
524
  layer: "none",
320
525
  degraded: true,
321
526
  warnings: [
322
- `Deny-layer enforcement inspection failed: ${msg}`,
527
+ "Deny-layer enforcement inspection failed: the probe could not be run.",
323
528
  "The executor claim loop MUST refuse to claim jobs until the deny layer is enforced.",
324
529
  ],
325
530
  };
@@ -456,3 +661,221 @@ export function formatConductorDoctorReport(report) {
456
661
  lines.push(formatClaudeLoginAdvisory(claude_login));
457
662
  return lines.join("\n");
458
663
  }
664
+ // ---------------------------------------------------------------------------
665
+ // Canonical readiness projection (BAPI-1055)
666
+ // ---------------------------------------------------------------------------
667
+ /**
668
+ * The complete conductor-doctor prerequisite set, in render order.
669
+ *
670
+ * Like the install-side descriptor set, this is STABLE rather than derived from
671
+ * whatever the report happened to contain: a report that failed to build, or an
672
+ * injected sentinel carrying only the fields a test cares about, must still
673
+ * yield every prerequisite — as an explicit unknown, never as an absence.
674
+ */
675
+ export const CONDUCTOR_READINESS_DESCRIPTORS = [
676
+ { id: "ledger", label: "Conductor event ledger" },
677
+ { id: "git-hooks", label: "Conductor git hooks" },
678
+ { id: "mcp-profile", label: "MCP conductor profile" },
679
+ { id: "gh-cli", label: "GitHub CLI availability" },
680
+ { id: "gh-auth", label: "GitHub CLI authentication" },
681
+ { id: "native-ledger", label: "Native ledger binding" },
682
+ { id: "mcp-registration-form", label: "Worker MCP registration form" },
683
+ { id: "node-engine", label: "Expected Node engine" },
684
+ { id: "deny-enforcement", label: "Deny-layer enforcement" },
685
+ { id: "claude-login", label: "Claude login" },
686
+ ];
687
+ /** Remediation for a degraded local event ledger. */
688
+ export const CONDUCTOR_LEDGER_REMEDIATION = "run `conductor doctor` for the ledger detail, then re-create the local ledger directory with " +
689
+ "owner-only permissions; the ledger is local observability and never blocks a run.";
690
+ /** Remediation for degraded or missing managed git hooks. */
691
+ export const CONDUCTOR_GIT_HOOKS_REMEDIATION = "run `conductor install-git-hooks` to install the managed post-commit and reference-transaction " +
692
+ "hooks; they are local, opportunistic, and bypassable.";
693
+ /** Remediation for a Claude login this host cannot confirm. */
694
+ export const CONDUCTOR_CLAUDE_LOGIN_REMEDIATION = "run `claude login` on the host that will run conductor workers; Bridge stores no Anthropic " +
695
+ "credential and can only advise here.";
696
+ /** Fixed detail for a prerequisite whose owning inspection was not collected. */
697
+ const CONDUCTOR_UNCOLLECTED_DETAIL = "not collected — the conductor doctor did not report this inspection";
698
+ /** Fixed remediation for a prerequisite whose owning inspection was not collected. */
699
+ const CONDUCTOR_UNCOLLECTED_REMEDIATION = "run `conductor doctor` directly to collect this inspection; its state is unknown, not healthy.";
700
+ /** Ordered outcomes an inspection contributed, or `[]` when it reported none. */
701
+ function outcomesOf(inspection) {
702
+ // Read defensively: an injected sentinel (and the installer's degraded-doctor
703
+ // fallback) may populate only the fields a test cares about, and an adapter
704
+ // that threw on a partially-built report would lose the whole locus.
705
+ return Array.isArray(inspection?.outcomes) ? inspection?.outcomes : [];
706
+ }
707
+ /** Derive the ledger outcome from the ledger doctor result's own `degraded` flag. */
708
+ function ledgerOutcome(report) {
709
+ const ledger = report?.ledger;
710
+ if (ledger === undefined)
711
+ return null;
712
+ // Fixed facts only: `DoctorResult.path` and `directory_path` are absolute and
713
+ // are deliberately never projected.
714
+ return ledger.degraded
715
+ ? {
716
+ id: "ledger",
717
+ label: "Conductor event ledger",
718
+ status: "warn",
719
+ detail: `local ledger degraded (schema present: ${ledger.schema_present}, permissions ok: ${ledger.directory_permissions_ok && ledger.database_permissions_ok})`,
720
+ remediation: CONDUCTOR_LEDGER_REMEDIATION,
721
+ }
722
+ : {
723
+ id: "ledger",
724
+ label: "Conductor event ledger",
725
+ status: "pass",
726
+ detail: `local ledger healthy (schema version ${ledger.user_version ?? "unknown"})`,
727
+ };
728
+ }
729
+ /** Derive the git-hooks outcome from the hook inspection's own `degraded` flag. */
730
+ function gitHooksOutcome(report) {
731
+ const hooks = report?.git_hooks;
732
+ if (hooks === undefined)
733
+ return null;
734
+ if (!hooks.is_worktree) {
735
+ return {
736
+ id: "git-hooks",
737
+ label: "Conductor git hooks",
738
+ status: "skip",
739
+ detail: "not a git worktree, so no managed conductor hooks apply",
740
+ };
741
+ }
742
+ // Hook NAMES and booleans only — `hooks_dir` is absolute and never projected.
743
+ const rendered = hooks.hooks
744
+ .map((hook) => `${hook.name}=${hook.exists && hook.managed_block_present}`)
745
+ .join(", ");
746
+ return hooks.degraded
747
+ ? {
748
+ id: "git-hooks",
749
+ label: "Conductor git hooks",
750
+ status: "warn",
751
+ detail: `managed hooks installed: ${rendered}`,
752
+ remediation: CONDUCTOR_GIT_HOOKS_REMEDIATION,
753
+ }
754
+ : {
755
+ id: "git-hooks",
756
+ label: "Conductor git hooks",
757
+ status: "pass",
758
+ detail: `managed hooks installed: ${rendered}`,
759
+ };
760
+ }
761
+ /**
762
+ * Derive the deny-enforcement outcome.
763
+ *
764
+ * `conductor readiness` NEVER runs the owning probe: `runDenyEnforcementPreflight`
765
+ * spawns real headless agent runs costing minutes, and an advisory report is not
766
+ * a reason to pay that. When the report carries an explicitly skipped result the
767
+ * outcome is `skip` with a remediation naming the command that DOES run it —
768
+ * never `pass`, which would claim enforcement that was never verified.
769
+ */
770
+ function denyEnforcementOutcome(report) {
771
+ const deny = report?.deny_enforcement;
772
+ if (deny === undefined)
773
+ return null;
774
+ if (deny.layer === "none" && !deny.enforced && deny.status === "skip") {
775
+ return {
776
+ id: "deny-enforcement",
777
+ label: "Deny-layer enforcement",
778
+ status: "skip",
779
+ detail: "not probed — enforcement is UNVERIFIED, which is not the same as unenforced",
780
+ remediation: DENY_ENFORCEMENT_SKIP_REMEDIATION,
781
+ };
782
+ }
783
+ // Fixed categories only: the underlying `detail`/`warnings` can carry probe
784
+ // text, so neither is projected here.
785
+ return deny.enforced && !deny.degraded
786
+ ? {
787
+ id: "deny-enforcement",
788
+ label: "Deny-layer enforcement",
789
+ status: "pass",
790
+ detail: `enforced by the ${deny.layer} layer`,
791
+ }
792
+ : {
793
+ id: "deny-enforcement",
794
+ label: "Deny-layer enforcement",
795
+ status: "fail",
796
+ detail: deny.enforced
797
+ ? `enforced by the ${deny.layer} layer, but degraded`
798
+ : "the denied call was not rejected by any layer",
799
+ remediation: "restore the settings-deny layer before running a conductor worker; the executor claim " +
800
+ "loop refuses to claim jobs while enforcement is missing.",
801
+ };
802
+ }
803
+ /** Derive the Claude-login outcome from the advisory detection result. */
804
+ function claudeLoginOutcome(report) {
805
+ const login = report?.claude_login;
806
+ if (login === undefined)
807
+ return null;
808
+ return login.detected
809
+ ? {
810
+ id: "claude-login",
811
+ label: "Claude login",
812
+ status: "pass",
813
+ detail: "a local Claude login marker was detected",
814
+ }
815
+ : {
816
+ id: "claude-login",
817
+ label: "Claude login",
818
+ status: "warn",
819
+ detail: "no local Claude login marker was detected",
820
+ remediation: CONDUCTOR_CLAUDE_LOGIN_REMEDIATION,
821
+ };
822
+ }
823
+ /**
824
+ * Project a conductor-doctor report into canonical readiness checks.
825
+ *
826
+ * PURE: no probe, no I/O, and no re-derivation of any verdict. Every outcome is
827
+ * read from the structured fields the owning inspections now populate, never
828
+ * parsed out of their free-text `warnings` — a warning is prose an author may
829
+ * reword, and a gate that read it would change meaning on a copy edit.
830
+ *
831
+ * A null report yields the full descriptor set as failures rather than nothing:
832
+ * "the conductor doctor could not be built" must not read as "there is nothing
833
+ * to report here".
834
+ */
835
+ export function mapConductorDoctorReportToReadinessChecks(report) {
836
+ const collected = new Map();
837
+ const record = (outcome) => {
838
+ // First writer wins, so an inspection cannot overwrite another's finding.
839
+ if (outcome && !collected.has(outcome.id))
840
+ collected.set(outcome.id, outcome);
841
+ };
842
+ record(ledgerOutcome(report));
843
+ record(gitHooksOutcome(report));
844
+ for (const outcome of outcomesOf(report?.mcp_profile))
845
+ record(outcome);
846
+ for (const outcome of outcomesOf(report?.local_merge))
847
+ record(outcome);
848
+ for (const outcome of outcomesOf(report?.native_ledger))
849
+ record(outcome);
850
+ record(denyEnforcementOutcome(report));
851
+ record(claudeLoginOutcome(report));
852
+ return CONDUCTOR_READINESS_DESCRIPTORS.map(({ id, label }) => {
853
+ const outcome = collected.get(id);
854
+ if (!outcome) {
855
+ // Unknown, and unknown is never healthy: a prerequisite whose owning
856
+ // inspection did not report is a failure with a restoring remediation.
857
+ return createReadinessCheck({
858
+ id: `conductor.${id}`,
859
+ source: "conductor",
860
+ label,
861
+ status: "fail",
862
+ detail: CONDUCTOR_UNCOLLECTED_DETAIL,
863
+ remediation: CONDUCTOR_UNCOLLECTED_REMEDIATION,
864
+ });
865
+ }
866
+ return createReadinessCheckSafely({
867
+ id: `conductor.${id}`,
868
+ source: "conductor",
869
+ label: outcome.label || label,
870
+ status: outcome.status,
871
+ ...(outcome.detail ? { detail: outcome.detail } : {}),
872
+ // Same fallback as the plane adapter: a real failure must never be
873
+ // downgraded into a generic "unreadable check" for want of a fix line.
874
+ ...(outcome.status === "fail"
875
+ ? { remediation: outcome.remediation ?? CONDUCTOR_UNCOLLECTED_REMEDIATION }
876
+ : outcome.status !== "pass" && outcome.remediation
877
+ ? { remediation: outcome.remediation }
878
+ : {}),
879
+ });
880
+ });
881
+ }