@bridge_gpt/mcp-server 0.2.51 → 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.
Files changed (79) hide show
  1. package/README.md +59 -13
  2. package/build/agent-capabilities/probe-context.js +15 -7
  3. package/build/agent-capabilities/probes.js +42 -6
  4. package/build/agent-launchers/claude-executor-adapter.js +98 -14
  5. package/build/commands.generated.js +7 -5
  6. package/build/conduct-epic/cut-protocol.js +17 -3
  7. package/build/conductor/bridge-api-client.js +232 -5
  8. package/build/conductor/cli.js +23 -0
  9. package/build/conductor/deny-enforcement-preflight.js +107 -10
  10. package/build/conductor/doctor.js +428 -5
  11. package/build/conductor/install-doctor.js +65 -656
  12. package/build/conductor/local-merge.js +170 -11
  13. package/build/conductor/readiness-cli.js +152 -0
  14. package/build/conductor/readiness-sections.js +666 -0
  15. package/build/conductor/readiness.js +710 -0
  16. package/build/conductor/tools.js +56 -3
  17. package/build/conductor-bin.js +21 -17
  18. package/build/connect-bitbucket-api.js +370 -0
  19. package/build/connect-bitbucket.js +437 -0
  20. package/build/docs.generated.js +1 -1
  21. package/build/doctor.js +40 -1
  22. package/build/drive-epic.js +423 -11
  23. package/build/env-file-link.js +164 -0
  24. package/build/epic-integration-pr.js +10 -0
  25. package/build/executor/cli.js +41 -6
  26. package/build/executor/deps.js +5 -1
  27. package/build/executor/env-file-guard.js +113 -0
  28. package/build/executor/env.js +78 -1
  29. package/build/executor/heartbeat.js +9 -0
  30. package/build/executor/http-client.js +90 -22
  31. package/build/executor/job-errors.js +43 -2
  32. package/build/executor/job-runner.js +130 -28
  33. package/build/executor/merge-job.js +67 -16
  34. package/build/executor/permissions.js +106 -0
  35. package/build/executor/preflight.js +38 -13
  36. package/build/executor/resume-pre-spawn.js +2 -1
  37. package/build/executor/runner.js +175 -4
  38. package/build/executor/service-unit.js +15 -0
  39. package/build/executor/terminal-mutation.js +22 -1
  40. package/build/executor/types.js +86 -0
  41. package/build/executor/worker-command.js +21 -5
  42. package/build/executor/worker-guard-hook.js +939 -0
  43. package/build/executor/worker-log.js +56 -0
  44. package/build/executor/worktree.js +11 -0
  45. package/build/git-reachability.js +147 -0
  46. package/build/index.js +4734 -4270
  47. package/build/install-bridge.js +95 -0
  48. package/build/install-doctor.js +154 -2
  49. package/build/pipelines.generated.js +6 -4
  50. package/build/plan-epic-conductor-eligibility.js +37 -7
  51. package/build/plane/alembic-head.js +40 -11
  52. package/build/plane/build-freshness.js +22 -11
  53. package/build/plane/cli.js +78 -15
  54. package/build/plane/defaults.js +165 -0
  55. package/build/plane/manifest.js +63 -8
  56. package/build/plane/member-logs.js +6 -0
  57. package/build/plane/member-roster.js +195 -11
  58. package/build/plane/preflight.js +402 -44
  59. package/build/plane/shutdown.js +25 -3
  60. package/build/plane/status.js +11 -0
  61. package/build/plane/supervisor.js +343 -14
  62. package/build/plane/test-fakes.js +43 -0
  63. package/build/plane/types.js +118 -11
  64. package/build/pr-base-contract.js +20 -0
  65. package/build/readiness-check.js +412 -0
  66. package/build/readme.generated.js +1 -1
  67. package/build/review-synthesis-config.js +60 -0
  68. package/build/scripts/executor-protocol-contract-driver.js +311 -0
  69. package/build/setup-epic.js +560 -139
  70. package/build/sfcc/log-query.js +2 -1
  71. package/build/start-tickets-conductor.js +11 -2
  72. package/build/start-tickets.js +69 -2
  73. package/build/version.generated.js +3 -3
  74. package/build/worker-containment-diagnostic.js +97 -0
  75. package/build/worker-guard-hook-bin.js +6 -0
  76. package/docs/CONDUCTOR.md +27 -0
  77. package/docs/install/mcp-tool-integrations.md +3 -2
  78. package/package.json +4 -3
  79. package/pipelines/{full-automation.json → idea-to-pr.json} +1 -1
@@ -0,0 +1,710 @@
1
+ /**
2
+ * The consolidated ADVISORY conductor readiness gate (BAPI-1055, AC-6/AC-7).
3
+ *
4
+ * One report that answers "is this host and repository ready to conduct an
5
+ * epic", assembled from the five loci that each answered part of it before:
6
+ * `bridge doctor`'s install checklist, `conductor doctor`'s local inspections,
7
+ * `plane` preflight, the server `conductor-readiness` collector, and the
8
+ * install-time doctor that already composed four of them. Every prerequisite
9
+ * comes back as a canonical {@link ReadinessCheck}: a stable id, a
10
+ * pass/warn/fail/skip status, and — on a failure — exactly ONE named
11
+ * remediation.
12
+ *
13
+ * It COMPOSES; it never re-probes. Each collector still owns its own probe, its
14
+ * own timeout, and its own verdict, and this module reads what they returned.
15
+ *
16
+ * ---
17
+ *
18
+ * # Risk boundary for the advisory-only decision (BAPI-1055, R-5)
19
+ *
20
+ * This gate reports and remediates. It never blocks.
21
+ *
22
+ * **Detection.** Every prerequisite that is not `pass` is emitted as a
23
+ * structured check with a named remediation: `warn` for a degraded but usable
24
+ * capability, `fail` for an unmet prerequisite OR one whose state could not be
25
+ * established, `skip` for a genuine dependency or applicability case. Unknown is
26
+ * never reported as healthy — an unreadable liveness read, a failed permission
27
+ * probe, `executor.ready === null`, and a field a newer server would have sent
28
+ * are all failures, not passes.
29
+ *
30
+ * **Containment.** The authoritative refusals are unchanged and live elsewhere:
31
+ * `V2_READINESS_REQUIREMENTS` in `drive-epic.ts` remains the SOLE hard
32
+ * route-selection predicate, and the server-side admission (A1b.1/A1b.2) remains
33
+ * the sole hard refusal. `plane up`'s own blocking preflight still refuses a
34
+ * bring-up on its own terms. This gate contributes to none of those decisions:
35
+ * it computes no overall verdict, changes no exit code, and is imported by no
36
+ * route-selection code. What containment does NOT cover is the gap this gate
37
+ * exists to fill — local and plane configuration problems that the server
38
+ * predicates cannot see at all, and that previously surfaced only as prose
39
+ * buried in a warning.
40
+ *
41
+ * **Escalation.** Findings go to the invoking operator as structured CLI output,
42
+ * with one named remediation each. That remediation is a description of an
43
+ * operator action; nothing here executes one. An ordinary configuration gap
44
+ * raises no Sentry event and pages nobody — it is a normal, expected report. A
45
+ * collector that fails PERSISTENTLY (the same source failing across runs) is the
46
+ * escalation case, and it escalates to whoever owns that host: the operator for
47
+ * a local collector, and the Bridge API owner for a server-side collector that
48
+ * keeps timing out or answering unreadably.
49
+ *
50
+ * ## Deferred retirement: local `gh` (AC-7)
51
+ *
52
+ * **Detection** is `conductor.gh-cli` and `conductor.gh-auth`, carried as two
53
+ * separate checks because installing `gh` and authenticating it are two
54
+ * different operator actions. **Containment** is the existing local-merge
55
+ * execution path, which refuses and emits `merge.failed`/`merge.skipped` rather
56
+ * than merging without auth — a missing `gh` degrades a run, it does not corrupt
57
+ * one. **Escalation** targets the invoking operator first; there is no automated
58
+ * fallback, because the GitHub App token is `contents: read` and structurally
59
+ * cannot merge (see `mcp_server/src/executor/merge-job.ts`). **Retirement** is
60
+ * owned by epic A2, after the App is elevated to `contents: write`. Until A2
61
+ * ships this check is load-bearing and no code here may remove, hide, or
62
+ * downgrade it.
63
+ *
64
+ * ## Deferred retirement: native SQLite ledger (AC-7)
65
+ *
66
+ * **Detection** is `conductor.native-ledger` plus the two Node-environment
67
+ * checks beside it (`conductor.mcp-registration-form`,
68
+ * `conductor.node-engine`). **Containment** is diagnostic degradation only: a
69
+ * ledger whose binding does not load costs local observability, and no code path
70
+ * here installs, rebuilds, or repairs a package (BAPI-526). **Escalation**
71
+ * targets the invoking operator, who reinstalls the optional dependency for
72
+ * their Node runtime. **Retirement** is owned by epic B3, after the parallel
73
+ * ledger is pared away. Until B3 ships this check is load-bearing.
74
+ *
75
+ * ---
76
+ *
77
+ * ## Schema versioning
78
+ *
79
+ * {@link CONDUCTOR_READINESS_GATE_SCHEMA_VERSION} is INDEPENDENT of the install
80
+ * doctor's section vocabulary, the conductor doctor's report, the plane
81
+ * diagnostic shape, and the Python wire response. Each of those evolves for its
82
+ * own reasons; binding this report's version to any of them would make an
83
+ * unrelated additive change look like a breaking one here, or worse, hide a real
84
+ * one.
85
+ */
86
+ import { mapConductorDoctorReportToReadinessChecks, } from "./doctor.js";
87
+ import { mapInstallStatusChecksToReadinessChecks, } from "../install-doctor.js";
88
+ import { mapPlanePreflightToReadinessChecks, } from "../plane/preflight.js";
89
+ import { runConductorInstallDoctor, } from "./install-doctor.js";
90
+ import { createReadinessCheck, createReadinessCheckSafely, summarizeReadinessChecks, validateReadinessChecks, } from "../readiness-check.js";
91
+ /** This report's own schema version. See the module docstring. */
92
+ export const CONDUCTOR_READINESS_GATE_SCHEMA_VERSION = 1;
93
+ /** Fixed statement carried in every report, so no consumer can mistake it for a gate. */
94
+ export const CONDUCTOR_READINESS_ADVISORY_NOTICE = "Advisory only. This report never blocks bring-up, approval, or a run, and never changes an " +
95
+ "exit code. Remediations are operator actions and are never executed automatically. " +
96
+ "drive-epic's V2_READINESS_REQUIREMENTS remains the only hard route-selection predicate, and " +
97
+ "the server-side admission remains the only hard refusal.";
98
+ // ---------------------------------------------------------------------------
99
+ // Server-fact mapper
100
+ // ---------------------------------------------------------------------------
101
+ /** The complete server-side prerequisite set, in render order. */
102
+ export const SERVER_READINESS_DESCRIPTORS = [
103
+ { id: "supervisor-setup", label: "Supervisor setup" },
104
+ { id: "supervisor-config", label: "Supervisor configuration" },
105
+ { id: "supervisor-required-checks", label: "Supervisor required CI checks" },
106
+ { id: "auto-merge", label: "Auto-merge posture" },
107
+ { id: "merge-approval", label: "Merge approval configuration" },
108
+ { id: "review-policy", label: "Review policy" },
109
+ { id: "github-credentials", label: "GitHub App credentials" },
110
+ { id: "github-actions-permission", label: "GitHub App actions permission" },
111
+ { id: "reconciler-liveness", label: "Reconciler tick liveness" },
112
+ { id: "executor-readiness", label: "Executor server observation" },
113
+ { id: "review-workflow", label: "Review workflow protocol" },
114
+ { id: "conductor-ci-workflow", label: "Conductor CI migration guard" },
115
+ ];
116
+ /** Fixed, secret-free prose per failure kind. Never a status body or header. */
117
+ const SERVER_FAILURE_COPY = {
118
+ "identity-unresolved": {
119
+ detail: "no server facts were read — the repository identity or credential could not be resolved",
120
+ remediation: "run /install-bridge (or set BAPI_REPO_NAME and BAPI_API_KEY) for this repository, then re-run.",
121
+ },
122
+ unauthorized: {
123
+ detail: "the Bridge API rejected the credential for this repository",
124
+ remediation: "re-check the API key and repository name, then re-run; the key may have been rotated.",
125
+ },
126
+ "invalid-response": {
127
+ detail: "the readiness response failed shape validation, so no server fact can be trusted",
128
+ remediation: "re-run; if it persists the Bridge API deploy is inconsistent with this CLI version.",
129
+ },
130
+ unreachable: {
131
+ detail: "the readiness endpoint could not be reached",
132
+ remediation: "check network reachability to the Bridge API and re-run; the local checks above are unaffected.",
133
+ },
134
+ };
135
+ /** Remediations for the individual server facts, as fixed prose. */
136
+ const SERVER_REMEDIATIONS = {
137
+ supervisorSetup: "run `install conductor` (or `setup-epic`) for this repository to write the project-default supervisor posture.",
138
+ supervisorConfig: "run `install conductor` for this repository to write the project-default supervisor configuration.",
139
+ requiredChecks: "configure a non-empty required-check list in done_gate_config; an empty list makes the done gate pass unconditionally.",
140
+ mergeApproval: "set the merge-approval requirement explicitly in the supervisor configuration rather than leaving it unset.",
141
+ reviewPolicyMissing: "set the repository review-policy default so the merge path and the code_review gate read the same signal.",
142
+ reviewPolicyDivergent: "align the repository review-policy default with the done gate's review_state condition, or confirm the difference is intended.",
143
+ githubCredentials: "connect GitHub from the Bridge setup UI (`install-bridge connect-github`) so owner, repository id, and installation id all resolve.",
144
+ actionsProbeFailed: "re-run once credentials resolve; the permission could not be checked, which is not the same as confirmed-missing.",
145
+ actionsMissing: "grant the GitHub App `actions: write`; without it the conductor's workflow rerun lane fails open on 403.",
146
+ reconcilerUnreadable: "retry once the Bridge API is reachable; reconciler liveness is unknown here, not confirmed stale.",
147
+ reconcilerStale: "start the reconciler for this repository (the `conductor epic-tick` schedule) so ticks resume.",
148
+ executorUnreadable: "retry once the Bridge API is reachable; no executor readiness claim is ever made from local state.",
149
+ executorNeverSeen: "provision and start an executor (`install conductor --executor-id <id>` or `executor install-service`); readiness comes only from a live server observation.",
150
+ executorStale: "restart the executor service; it was observed once and has gone quiet past the server's staleness threshold.",
151
+ reviewWorkflowUnknown: "re-run; the default branch's review workflow could not be evaluated, which is not the same as it being absent.",
152
+ reviewWorkflowIncomplete: "install the claude-review workflow template on the default branch so it emits both the run id and the reviewed SHA the verdict protocol reads.",
153
+ ciWorkflowUnknown: "re-run; the default branch's conductor CI workflow could not be evaluated, which is not the same as the guard being absent.",
154
+ ciWorkflowMissingGuard: "add the migration-guard job to the conductor CI workflow on the default branch so migrations are checked before a merge.",
155
+ olderServer: "upgrade the Bridge API deploy so it reports this fact; its state is unknown here, not healthy.",
156
+ };
157
+ /**
158
+ * Project the validated server readiness response into `server.*` checks.
159
+ *
160
+ * PURE. It re-derives nothing: the server's own `stale` and `ready` conclusions
161
+ * and its own threshold values are reported as given, and
162
+ * `V2_READINESS_REQUIREMENTS` is neither imported nor duplicated here — two
163
+ * implementations of one classification drift, and this one is advisory while
164
+ * that one decides a route.
165
+ *
166
+ * Uncertainty is never healthy. `actions_probe_succeeded === false`, an
167
+ * unreadable liveness read, `executor.ready === null`, and a field an older
168
+ * server never sent each map to `fail` with a remediation for RESTORING the
169
+ * probe — kept deliberately distinct from the confirmed-negative remediation,
170
+ * because "could not be checked" and "checked and absent" need different
171
+ * actions.
172
+ *
173
+ * Passing `null` (with a `failure` kind) yields the full descriptor set as
174
+ * failures, so an unreachable server loses no prerequisite from the report.
175
+ */
176
+ export function mapServerReadinessToChecks(readiness, failure = "unreachable") {
177
+ if (readiness === null) {
178
+ const copy = SERVER_FAILURE_COPY[failure];
179
+ return SERVER_READINESS_DESCRIPTORS.map(({ id, label }) => createReadinessCheck({
180
+ id: `server.${id}`,
181
+ source: "server",
182
+ label,
183
+ status: "fail",
184
+ detail: copy.detail,
185
+ remediation: copy.remediation,
186
+ }));
187
+ }
188
+ const s = readiness.supervisor;
189
+ const g = readiness.github;
190
+ const r = readiness.reconciler;
191
+ const e = readiness.executor;
192
+ const outcomes = [];
193
+ outcomes.push(s.setup_present
194
+ ? { id: "supervisor-setup", status: "pass", detail: `stored (source: ${s.setup_source})` }
195
+ : {
196
+ id: "supervisor-setup",
197
+ status: "fail",
198
+ detail: "no supervisor setup is stored for this repository",
199
+ remediation: SERVER_REMEDIATIONS.supervisorSetup,
200
+ });
201
+ outcomes.push(s.config_present
202
+ ? { id: "supervisor-config", status: "pass", detail: `stored (source: ${s.config_source})` }
203
+ : {
204
+ id: "supervisor-config",
205
+ status: "fail",
206
+ detail: "no supervisor configuration is stored for this repository",
207
+ remediation: SERVER_REMEDIATIONS.supervisorConfig,
208
+ });
209
+ // Counts and booleans are safe facts and stay in `detail`; they never become
210
+ // checks of their own, because there is nothing separate to remediate.
211
+ outcomes.push(s.required_checks_empty
212
+ ? {
213
+ id: "supervisor-required-checks",
214
+ status: "warn",
215
+ detail: "the required-check list is EMPTY — the done gate would pass unconditionally",
216
+ remediation: SERVER_REMEDIATIONS.requiredChecks,
217
+ }
218
+ : {
219
+ id: "supervisor-required-checks",
220
+ status: "pass",
221
+ detail: `${s.required_checks_count} required check(s) configured`,
222
+ });
223
+ // Auto-merge OFF is a posture, not a gap: an operator may deliberately merge
224
+ // by hand. Reported, never remediated.
225
+ outcomes.push({
226
+ id: "auto-merge",
227
+ status: "pass",
228
+ detail: s.auto_merge_enabled ? "auto-merge enabled" : "auto-merge disabled (operator posture)",
229
+ });
230
+ outcomes.push(s.merge_approval_required_set
231
+ ? { id: "merge-approval", status: "pass", detail: "merge-approval requirement is set explicitly" }
232
+ : {
233
+ id: "merge-approval",
234
+ status: "warn",
235
+ detail: "the merge-approval requirement is unset, so the merge path falls back to its default",
236
+ remediation: SERVER_REMEDIATIONS.mergeApproval,
237
+ });
238
+ outcomes.push(reviewPolicyOutcome(readiness));
239
+ outcomes.push(g.credentials_complete
240
+ ? {
241
+ id: "github-credentials",
242
+ status: "pass",
243
+ detail: "owner, repository id, and installation id all resolved",
244
+ }
245
+ : {
246
+ id: "github-credentials",
247
+ status: "fail",
248
+ detail: g.credentials_readable
249
+ ? `incomplete: ${[
250
+ !g.owner_resolved ? "owner" : null,
251
+ !g.repo_id_resolved ? "repository id" : null,
252
+ !g.installation_id_resolved ? "installation id" : null,
253
+ ]
254
+ .filter(Boolean)
255
+ .join(", ")} unresolved`
256
+ : "no GitHub credential row exists for this repository",
257
+ remediation: SERVER_REMEDIATIONS.githubCredentials,
258
+ });
259
+ outcomes.push(githubActionsOutcome(readiness));
260
+ outcomes.push(reconcilerOutcome(readiness));
261
+ outcomes.push(executorOutcome(readiness));
262
+ outcomes.push(reviewWorkflowOutcome(readiness));
263
+ outcomes.push(ciWorkflowOutcome(readiness));
264
+ const byId = new Map(outcomes.map((outcome) => [outcome.id, outcome]));
265
+ return SERVER_READINESS_DESCRIPTORS.map(({ id, label }) => {
266
+ const outcome = byId.get(id);
267
+ if (!outcome) {
268
+ return createReadinessCheck({
269
+ id: `server.${id}`,
270
+ source: "server",
271
+ label,
272
+ status: "fail",
273
+ detail: "this server fact was not reported",
274
+ remediation: SERVER_REMEDIATIONS.olderServer,
275
+ });
276
+ }
277
+ return createReadinessCheckSafely({
278
+ id: `server.${id}`,
279
+ source: "server",
280
+ label,
281
+ status: outcome.status,
282
+ detail: outcome.detail,
283
+ ...(outcome.status === "pass"
284
+ ? {}
285
+ : { remediation: outcome.remediation ?? SERVER_REMEDIATIONS.olderServer }),
286
+ });
287
+ });
288
+ }
289
+ /** Review-policy presence and alignment, as one check with one fix each. */
290
+ function reviewPolicyOutcome(readiness) {
291
+ const s = readiness.supervisor;
292
+ const alignment = s.review_policy_alignment;
293
+ if (alignment !== null) {
294
+ if (alignment.status === "divergent" || alignment.status === "invalid") {
295
+ return {
296
+ id: "review-policy",
297
+ status: "warn",
298
+ // The server authors one operator-facing sentence for this; it is a
299
+ // constrained, server-owned explanation, not free-form error text.
300
+ detail: alignment.explanation,
301
+ remediation: SERVER_REMEDIATIONS.reviewPolicyDivergent,
302
+ };
303
+ }
304
+ return { id: "review-policy", status: "pass", detail: alignment.explanation };
305
+ }
306
+ // A pre-alignment server cannot report alignment. Presence is still knowable.
307
+ return s.review_policy_present
308
+ ? {
309
+ id: "review-policy",
310
+ status: "pass",
311
+ detail: "a repository review-policy default is stored (alignment not reported by this server)",
312
+ }
313
+ : {
314
+ id: "review-policy",
315
+ status: "warn",
316
+ detail: "no repository review-policy default is stored",
317
+ remediation: SERVER_REMEDIATIONS.reviewPolicyMissing,
318
+ };
319
+ }
320
+ /**
321
+ * GitHub Actions permission.
322
+ *
323
+ * A FAILED PROBE and a CONFIRMED insufficient permission are two findings with
324
+ * two fixes — retry the probe, versus grant the permission — and collapsing them
325
+ * would send an operator to change a permission that may already be correct.
326
+ */
327
+ function githubActionsOutcome(readiness) {
328
+ const g = readiness.github;
329
+ if (g.actions_write) {
330
+ return { id: "github-actions-permission", status: "pass", detail: "actions: write" };
331
+ }
332
+ if (!g.actions_probe_succeeded) {
333
+ return {
334
+ id: "github-actions-permission",
335
+ status: "fail",
336
+ detail: "the actions permission could not be checked (this is not a confirmed absence)",
337
+ remediation: SERVER_REMEDIATIONS.actionsProbeFailed,
338
+ };
339
+ }
340
+ return {
341
+ id: "github-actions-permission",
342
+ status: "warn",
343
+ detail: `actions: ${g.actions_permission_level}`,
344
+ remediation: SERVER_REMEDIATIONS.actionsMissing,
345
+ };
346
+ }
347
+ /** Reconciler liveness, preserving the server's own `stale` conclusion. */
348
+ function reconcilerOutcome(readiness) {
349
+ const r = readiness.reconciler;
350
+ if (!r.liveness_readable) {
351
+ return {
352
+ id: "reconciler-liveness",
353
+ status: "fail",
354
+ detail: "reconciler liveness could not be read",
355
+ remediation: SERVER_REMEDIATIONS.reconcilerUnreadable,
356
+ };
357
+ }
358
+ if (!r.stale) {
359
+ return {
360
+ id: "reconciler-liveness",
361
+ status: "pass",
362
+ detail: `last tick ${r.last_tick_age_seconds}s ago across ${r.active_run_count} active run(s)`,
363
+ };
364
+ }
365
+ return {
366
+ id: "reconciler-liveness",
367
+ status: "fail",
368
+ detail: r.last_tick_at === null
369
+ ? "no reconciler tick has been recorded for this repository"
370
+ : `last tick ${r.last_tick_age_seconds}s ago (server threshold ${readiness.thresholds.reconciler_stale_after_seconds}s)`,
371
+ remediation: SERVER_REMEDIATIONS.reconcilerStale,
372
+ };
373
+ }
374
+ /**
375
+ * Executor readiness — the AUTHORITATIVE executor fact.
376
+ *
377
+ * The three not-ready shapes stay distinct because they mean different things
378
+ * and need different actions: `null` is "never observed", `false` is "observed
379
+ * but stale", and an unreadable payload is "unknown". Collapsing them would let
380
+ * a stale executor read like a fresh install, or the reverse.
381
+ */
382
+ function executorOutcome(readiness) {
383
+ const e = readiness.executor;
384
+ if (!e.liveness_readable) {
385
+ return {
386
+ id: "executor-readiness",
387
+ status: "fail",
388
+ detail: "executor liveness could not be read",
389
+ remediation: SERVER_REMEDIATIONS.executorUnreadable,
390
+ };
391
+ }
392
+ if (e.ready === true) {
393
+ return {
394
+ id: "executor-readiness",
395
+ status: "pass",
396
+ detail: `executor ready — last seen ${e.last_seen_age_seconds}s ago`,
397
+ };
398
+ }
399
+ if (e.ready === false) {
400
+ return {
401
+ id: "executor-readiness",
402
+ status: "fail",
403
+ detail: `observed but not ready — last seen ${e.last_seen_age_seconds}s ago (server threshold ${readiness.thresholds.executor_stale_after_seconds}s)`,
404
+ remediation: SERVER_REMEDIATIONS.executorStale,
405
+ };
406
+ }
407
+ return {
408
+ id: "executor-readiness",
409
+ status: "fail",
410
+ detail: "never observed by the server",
411
+ remediation: SERVER_REMEDIATIONS.executorNeverSeen,
412
+ };
413
+ }
414
+ /** Review-workflow protocol facts. `null` means an older server, not health. */
415
+ function reviewWorkflowOutcome(readiness) {
416
+ const w = readiness.review_workflow;
417
+ if (w === null) {
418
+ return {
419
+ id: "review-workflow",
420
+ status: "fail",
421
+ detail: "this Bridge API deploy did not report the review-workflow facts",
422
+ remediation: SERVER_REMEDIATIONS.olderServer,
423
+ };
424
+ }
425
+ if (!w.probe_succeeded) {
426
+ return {
427
+ id: "review-workflow",
428
+ status: "fail",
429
+ detail: w.workflow_present
430
+ ? "the review workflow is present but could not be evaluated"
431
+ : "the review workflow could not be found or read on the default branch",
432
+ remediation: SERVER_REMEDIATIONS.reviewWorkflowUnknown,
433
+ };
434
+ }
435
+ if (w.emits_run_id && w.emits_reviewed_sha) {
436
+ return {
437
+ id: "review-workflow",
438
+ status: "pass",
439
+ detail: "the default branch's review workflow emits both protocol markers",
440
+ };
441
+ }
442
+ return {
443
+ id: "review-workflow",
444
+ status: "fail",
445
+ detail: `missing marker(s): ${[
446
+ !w.emits_run_id ? "run id" : null,
447
+ !w.emits_reviewed_sha ? "reviewed SHA" : null,
448
+ ]
449
+ .filter(Boolean)
450
+ .join(", ")}`,
451
+ remediation: SERVER_REMEDIATIONS.reviewWorkflowIncomplete,
452
+ };
453
+ }
454
+ /** Conductor-CI migration-guard facts. `null` means an older server. */
455
+ function ciWorkflowOutcome(readiness) {
456
+ const c = readiness.conductor_ci_workflow;
457
+ if (c === null) {
458
+ return {
459
+ id: "conductor-ci-workflow",
460
+ status: "fail",
461
+ detail: "this Bridge API deploy did not report the conductor-CI workflow facts",
462
+ remediation: SERVER_REMEDIATIONS.olderServer,
463
+ };
464
+ }
465
+ if (!c.probe_succeeded) {
466
+ return {
467
+ id: "conductor-ci-workflow",
468
+ status: "fail",
469
+ detail: c.workflow_present
470
+ ? "the conductor CI workflow is present but could not be evaluated"
471
+ : "the conductor CI workflow could not be found or read on the default branch",
472
+ remediation: SERVER_REMEDIATIONS.ciWorkflowUnknown,
473
+ };
474
+ }
475
+ return c.migration_guard_present
476
+ ? {
477
+ id: "conductor-ci-workflow",
478
+ status: "pass",
479
+ detail: "the default branch's conductor CI workflow runs the migration guard",
480
+ }
481
+ : {
482
+ id: "conductor-ci-workflow",
483
+ status: "warn",
484
+ detail: "the conductor CI workflow does not run the migration guard",
485
+ remediation: SERVER_REMEDIATIONS.ciWorkflowMissingGuard,
486
+ };
487
+ }
488
+ // ---------------------------------------------------------------------------
489
+ // Install-time section adapter
490
+ // ---------------------------------------------------------------------------
491
+ /**
492
+ * Sections the install-time doctor builds that the OWNING collectors also
493
+ * report through their own structured outcomes.
494
+ *
495
+ * Deduplication prefers the owner: a section is a prose interpretation of a
496
+ * probe, while the owning collector's outcome is the probe's own structured
497
+ * result, and reporting both would show one prerequisite twice with two
498
+ * wordings. The install-side ids that overlap are listed rather than matched by
499
+ * heuristic, so adding a section can never silently start shadowing an owner.
500
+ */
501
+ const INSTALL_SECTION_IDS_OWNED_ELSEWHERE = new Set([
502
+ // Server facts — owned by `mapServerReadinessToChecks`.
503
+ "supervisor-configuration",
504
+ "supervisor-required-checks",
505
+ "supervisor-review-alignment",
506
+ "github-credentials",
507
+ "github-actions-permission",
508
+ "reconciler-liveness",
509
+ "executor-observation",
510
+ "server-readiness",
511
+ "bridge-access",
512
+ // Local conductor facts — owned by `mapConductorDoctorReportToReadinessChecks`.
513
+ "conductor-ledger",
514
+ "conductor-ledger-loadability",
515
+ // Legacy embedded reports — owned by their own adapters.
516
+ "install-status",
517
+ ]);
518
+ /** Map the install doctor's own status vocabulary onto the canonical one. */
519
+ function sectionStatus(status) {
520
+ // `fatal` becomes `fail`, and NOTHING else follows from it here: the exit-code
521
+ // consequence stays with the installer's `conductorInstallDoctorHasFatal`.
522
+ if (status === "fatal")
523
+ return "fail";
524
+ return status === "degraded" ? "warn" : "pass";
525
+ }
526
+ /**
527
+ * Project the install-time doctor's remaining sections into `conductor.*` checks.
528
+ *
529
+ * These are the prerequisites no other collector owns — executor provisioning,
530
+ * local service state, the host profile token, hook targets, and the workflow
531
+ * presence section. Ids are prefixed `install-section-` so they cannot collide
532
+ * with a conductor-doctor outcome that happens to share a name.
533
+ */
534
+ export function mapInstallSectionsToReadinessChecks(sections) {
535
+ const checks = [];
536
+ const seen = new Set();
537
+ for (const section of sections ?? []) {
538
+ if (INSTALL_SECTION_IDS_OWNED_ELSEWHERE.has(section.id))
539
+ continue;
540
+ if (seen.has(section.id))
541
+ continue;
542
+ seen.add(section.id);
543
+ const status = sectionStatus(section.status);
544
+ checks.push(createReadinessCheckSafely({
545
+ id: `conductor.install-section-${section.id}`,
546
+ source: "conductor",
547
+ label: section.label,
548
+ status,
549
+ detail: section.detail,
550
+ ...(status === "pass"
551
+ ? {}
552
+ : {
553
+ remediation: section.remediation ??
554
+ "run `install conductor` for this repository to re-provision this capability.",
555
+ }),
556
+ }));
557
+ }
558
+ return checks;
559
+ }
560
+ /**
561
+ * Collect the consolidated advisory readiness report.
562
+ *
563
+ * NEVER THROWS. A collector that fails does not remove its prerequisites from
564
+ * the report — they come back as failed checks with restoring remediations, so a
565
+ * broken source can never make a report look shorter and healthier.
566
+ *
567
+ * The two independent collections (install-time doctor, plane preflight) run
568
+ * concurrently. No retry and no additional timeout is introduced: each collector
569
+ * keeps its own.
570
+ */
571
+ export async function collectConductorReadinessGate(deps) {
572
+ const [installReport, planeResult] = await Promise.all([
573
+ runInstallDoctorSafely(deps),
574
+ runPlaneSafely(deps),
575
+ ]);
576
+ const unavailableSources = [];
577
+ // The install-time doctor is the collector for the install, conductor-section,
578
+ // and server legs; losing it loses all three.
579
+ if (installReport === null) {
580
+ unavailableSources.push("install", "conductor", "server");
581
+ }
582
+ // A plane preflight was requested and could not be run. Not requesting one is
583
+ // not an unavailability — its prerequisites are still reported as unknowns.
584
+ if (deps.runPlanePreflight && planeResult === null)
585
+ unavailableSources.push("plane");
586
+ const checks = [
587
+ ...mapInstallStatusChecksToReadinessChecks((installReport?.legacyInstallChecks ?? null), installReport === null),
588
+ ...mapConductorDoctorReportToReadinessChecks(installReport?.legacyConductor ?? null),
589
+ ...mapInstallSectionsToReadinessChecks(installReport?.sections ?? null),
590
+ ...mapPlanePreflightToReadinessChecks(planeResult),
591
+ ...mapServerReadinessToChecks(installReport?.readiness ?? null, classifyServerFailure(installReport)),
592
+ ];
593
+ // Validate the ASSEMBLED report, not just the pieces: an injected collector or
594
+ // a future adapter could hand over an entry that violates the contract, and a
595
+ // malformed entry becomes a fixed source-scoped failure rather than escaping
596
+ // into output or dropping the locus.
597
+ const validated = validateReadinessChecks(checks);
598
+ return {
599
+ schemaVersion: CONDUCTOR_READINESS_GATE_SCHEMA_VERSION,
600
+ advisory: CONDUCTOR_READINESS_ADVISORY_NOTICE,
601
+ repoName: deps.repoName ?? null,
602
+ unavailableSources,
603
+ checks: validated,
604
+ summary: summarizeReadinessChecks(validated),
605
+ };
606
+ }
607
+ /** Run the install-time doctor, containing any throw. */
608
+ async function runInstallDoctorSafely(deps) {
609
+ try {
610
+ if (deps.runInstallDoctor)
611
+ return await deps.runInstallDoctor();
612
+ if (!deps.installDoctor)
613
+ return null;
614
+ return await runConductorInstallDoctor(deps.conductorDoctorDeps
615
+ ? { ...deps.installDoctor, conductorDoctorDeps: deps.conductorDoctorDeps }
616
+ : deps.installDoctor);
617
+ }
618
+ catch {
619
+ // It is documented never to throw; this contains an injected fake so the
620
+ // whole report is not lost to one collector.
621
+ return null;
622
+ }
623
+ }
624
+ /** Run the plane preflight, containing any throw. */
625
+ async function runPlaneSafely(deps) {
626
+ if (!deps.runPlanePreflight)
627
+ return null;
628
+ try {
629
+ return await deps.runPlanePreflight();
630
+ }
631
+ catch {
632
+ return null;
633
+ }
634
+ }
635
+ /**
636
+ * Why the server facts are missing, when they are.
637
+ *
638
+ * Read from the install doctor's OWN fatal/degraded section rather than
639
+ * re-classified here: it already distinguishes an unresolved identity, a
640
+ * rejected credential, a malformed body, and an unreachable endpoint, and a
641
+ * second classification would be a second chance to disagree with the first.
642
+ */
643
+ function classifyServerFailure(report) {
644
+ if (report === null)
645
+ return "unreachable";
646
+ const sections = report.sections ?? [];
647
+ if (sections.some((section) => section.id === "bridge-access"))
648
+ return "identity-unresolved";
649
+ const serverSection = sections.find((section) => section.id === "server-readiness");
650
+ if (!serverSection)
651
+ return "unreachable";
652
+ if (serverSection.detail.includes("rejected the credential"))
653
+ return "unauthorized";
654
+ if (serverSection.detail.includes("shape validation"))
655
+ return "invalid-response";
656
+ return "unreachable";
657
+ }
658
+ // ---------------------------------------------------------------------------
659
+ // Rendering
660
+ // ---------------------------------------------------------------------------
661
+ /** Status tag, matching the visual hierarchy of the other conductor reports. */
662
+ function readinessTag(status) {
663
+ if (status === "fail")
664
+ return "✗ FAIL";
665
+ if (status === "warn")
666
+ return "! WARN";
667
+ if (status === "skip")
668
+ return "- SKIP";
669
+ return "✓ PASS";
670
+ }
671
+ /**
672
+ * Render the consolidated report for a terminal.
673
+ *
674
+ * Pure formatting. It carries the SAME content the JSON serialization does —
675
+ * every canonical check, its source, its status, its detail, and its
676
+ * remediation — so neither rendering can show an operator a finding the other
677
+ * hides. It computes no overall verdict, because there is none to compute.
678
+ */
679
+ export function formatConductorReadinessGateReport(report) {
680
+ const lines = [
681
+ "Conductor readiness (advisory)",
682
+ "══════════════════════════════",
683
+ `schema version: ${report.schemaVersion}`,
684
+ `repository: ${report.repoName ?? "unresolved"}`,
685
+ "",
686
+ ];
687
+ for (const source of ["install", "conductor", "plane", "server"]) {
688
+ const group = report.checks.filter((check) => check.source === source);
689
+ if (group.length === 0)
690
+ continue;
691
+ lines.push(`[${source}]`);
692
+ for (const check of group) {
693
+ lines.push(` ${readinessTag(check.status)} ${check.label} (${check.id})`);
694
+ if (check.detail)
695
+ lines.push(` ${check.detail}`);
696
+ if (check.remediation)
697
+ lines.push(` → ${check.remediation}`);
698
+ }
699
+ lines.push("");
700
+ }
701
+ const { pass, warn, fail, skip } = report.summary;
702
+ lines.push(`summary: ${pass} pass, ${warn} warn, ${fail} fail, ${skip} skip`);
703
+ if (report.unavailableSources.length > 0) {
704
+ lines.push(`sources that could not be collected: ${report.unavailableSources.join(", ")} ` +
705
+ "(their prerequisites above are reported as failures, not as passes)");
706
+ }
707
+ lines.push("");
708
+ lines.push(report.advisory);
709
+ return lines.join("\n");
710
+ }