@bridge_gpt/mcp-server 0.2.52 → 0.2.54

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 (52) hide show
  1. package/README.md +121 -15
  2. package/build/agent-launchers/claude.js +3 -3
  3. package/build/agent-launchers/prompt.js +8 -11
  4. package/build/base-ref.js +33 -9
  5. package/build/bounded-wait.js +174 -0
  6. package/build/commands.generated.js +7 -5
  7. package/build/conductor/bridge-api-client.js +97 -8
  8. package/build/conductor/cli.js +23 -0
  9. package/build/conductor/doctor.js +428 -5
  10. package/build/conductor/epic-runtime.js +133 -97
  11. package/build/conductor/install-doctor.js +65 -656
  12. package/build/conductor/readiness-cli.js +152 -0
  13. package/build/conductor/readiness-sections.js +666 -0
  14. package/build/conductor/readiness.js +795 -0
  15. package/build/conductor/run-branch.js +137 -0
  16. package/build/conductor/test-run-branch-vectors.js +165 -0
  17. package/build/conductor/tools.js +56 -3
  18. package/build/conductor-bin.js +21 -17
  19. package/build/doctor.js +68 -1
  20. package/build/drive-epic.js +287 -51
  21. package/build/executor/claim-scope.js +104 -0
  22. package/build/executor/cli.js +14 -25
  23. package/build/executor/env-file-guard.js +82 -3
  24. package/build/executor/job-runner.js +60 -0
  25. package/build/index.js +4496 -4697
  26. package/build/install-doctor.js +154 -2
  27. package/build/local-artifact-storage.js +130 -0
  28. package/build/pipelines.generated.js +17 -10
  29. package/build/plane/alembic-head.js +40 -11
  30. package/build/plane/build-freshness.js +22 -11
  31. package/build/plane/cli.js +285 -36
  32. package/build/plane/manifest.js +209 -1
  33. package/build/plane/member-roster.js +70 -0
  34. package/build/plane/preflight.js +363 -48
  35. package/build/plane/shutdown.js +14 -1
  36. package/build/plane/status.js +35 -1
  37. package/build/plane/supervisor.js +546 -164
  38. package/build/plane/types.js +61 -2
  39. package/build/polling-policy.js +72 -0
  40. package/build/readiness-check.js +412 -0
  41. package/build/readme.generated.js +1 -1
  42. package/build/review-generation.js +219 -0
  43. package/build/run-unit-tests-launcher.js +5 -0
  44. package/build/setup-epic.js +514 -23
  45. package/build/ticket-key-utils.js +4 -3
  46. package/build/ticket-review-artifact-gate.js +461 -0
  47. package/build/upgrade-cli.js +5 -26
  48. package/build/version.generated.js +3 -3
  49. package/docs/install/mcp-tool-integrations.md +23 -1
  50. package/package.json +2 -2
  51. package/pipelines/{full-automation.json → idea-to-pr.json} +1 -1
  52. package/pipelines/review-ticket.json +17 -4
@@ -0,0 +1,795 @@
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
+ // BAPI-1102 — the two ONE-TIME unattended prerequisites plus the aggregate the
116
+ // third unattended refusal reads. Noun phrases, like every row above: these
117
+ // render as list items under a source heading, not as sentences.
118
+ { id: "unattended-consent", label: "Repository unattended consent" },
119
+ { id: "unattended-notify-default", label: "Verified notify webhook default" },
120
+ { id: "repository-workflow-readiness", label: "Repository workflow readiness" },
121
+ ];
122
+ /** Fixed, secret-free prose per failure kind. Never a status body or header. */
123
+ const SERVER_FAILURE_COPY = {
124
+ "identity-unresolved": {
125
+ detail: "no server facts were read — the repository identity or credential could not be resolved",
126
+ remediation: "run /install-bridge (or set BAPI_REPO_NAME and BAPI_API_KEY) for this repository, then re-run.",
127
+ },
128
+ unauthorized: {
129
+ detail: "the Bridge API rejected the credential for this repository",
130
+ remediation: "re-check the API key and repository name, then re-run; the key may have been rotated.",
131
+ },
132
+ "invalid-response": {
133
+ detail: "the readiness response failed shape validation, so no server fact can be trusted",
134
+ remediation: "re-run; if it persists the Bridge API deploy is inconsistent with this CLI version.",
135
+ },
136
+ unreachable: {
137
+ detail: "the readiness endpoint could not be reached",
138
+ remediation: "check network reachability to the Bridge API and re-run; the local checks above are unaffected.",
139
+ },
140
+ };
141
+ /** Remediations for the individual server facts, as fixed prose. */
142
+ const SERVER_REMEDIATIONS = {
143
+ supervisorSetup: "run `install conductor` (or `setup-epic`) for this repository to write the project-default supervisor posture.",
144
+ supervisorConfig: "run `install conductor` for this repository to write the project-default supervisor configuration.",
145
+ requiredChecks: "configure a non-empty required-check list in done_gate_config; an empty list makes the done gate pass unconditionally.",
146
+ mergeApproval: "set the merge-approval requirement explicitly in the supervisor configuration rather than leaving it unset.",
147
+ reviewPolicyMissing: "set the repository review-policy default so the merge path and the code_review gate read the same signal.",
148
+ reviewPolicyDivergent: "align the repository review-policy default with the done gate's review_state condition, or confirm the difference is intended.",
149
+ githubCredentials: "connect GitHub from the Bridge setup UI (`install-bridge connect-github`) so owner, repository id, and installation id all resolve.",
150
+ actionsProbeFailed: "re-run once credentials resolve; the permission could not be checked, which is not the same as confirmed-missing.",
151
+ actionsMissing: "grant the GitHub App `actions: write`; without it the conductor's workflow rerun lane fails open on 403.",
152
+ reconcilerUnreadable: "retry once the Bridge API is reachable; reconciler liveness is unknown here, not confirmed stale.",
153
+ reconcilerStale: "start the reconciler for this repository (the `conductor epic-tick` schedule) so ticks resume.",
154
+ executorUnreadable: "retry once the Bridge API is reachable; no executor readiness claim is ever made from local state.",
155
+ executorNeverSeen: "provision and start an executor (`install conductor --executor-id <id>` or `executor install-service`); readiness comes only from a live server observation.",
156
+ executorStale: "restart the executor service; it was observed once and has gone quiet past the server's staleness threshold.",
157
+ reviewWorkflowUnknown: "re-run; the default branch's review workflow could not be evaluated, which is not the same as it being absent.",
158
+ 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.",
159
+ ciWorkflowUnknown: "re-run; the default branch's conductor CI workflow could not be evaluated, which is not the same as the guard being absent.",
160
+ ciWorkflowMissingGuard: "add the migration-guard job to the conductor CI workflow on the default branch so migrations are checked before a merge.",
161
+ olderServer: "upgrade the Bridge API deploy so it reports this fact; its state is unknown here, not healthy.",
162
+ // BAPI-1102. Each names the ONE action that clears it, and none of them
163
+ // mentions a webhook URL, a destination, or any configuration value.
164
+ unattendedConsent: "set `unattended_conductor_allowed` for this repository (docs/claude/account-settings-operator-runbook.md), or run setup with --attended.",
165
+ unattendedNotifyDefault: "supply a --policy-file declaring notify.webhook_url, or run setup with --attended; an unattended run must have a working escalation channel, and notify.local_sink is not one.",
166
+ repositoryWorkflowReadiness: "run `install-bridge conductor` so the review and conductor-CI workflows are confirmed on the default branch, or run setup with --attended.",
167
+ };
168
+ /**
169
+ * Project the validated server readiness response into `server.*` checks.
170
+ *
171
+ * PURE. It re-derives nothing: the server's own `stale` and `ready` conclusions
172
+ * and its own threshold values are reported as given, and
173
+ * `V2_READINESS_REQUIREMENTS` is neither imported nor duplicated here — two
174
+ * implementations of one classification drift, and this one is advisory while
175
+ * that one decides a route.
176
+ *
177
+ * Uncertainty is never healthy. `actions_probe_succeeded === false`, an
178
+ * unreadable liveness read, `executor.ready === null`, and a field an older
179
+ * server never sent each map to `fail` with a remediation for RESTORING the
180
+ * probe — kept deliberately distinct from the confirmed-negative remediation,
181
+ * because "could not be checked" and "checked and absent" need different
182
+ * actions.
183
+ *
184
+ * Passing `null` (with a `failure` kind) yields the full descriptor set as
185
+ * failures, so an unreachable server loses no prerequisite from the report.
186
+ */
187
+ export function mapServerReadinessToChecks(readiness, failure = "unreachable") {
188
+ if (readiness === null) {
189
+ const copy = SERVER_FAILURE_COPY[failure];
190
+ return SERVER_READINESS_DESCRIPTORS.map(({ id, label }) => createReadinessCheck({
191
+ id: `server.${id}`,
192
+ source: "server",
193
+ label,
194
+ status: "fail",
195
+ detail: copy.detail,
196
+ remediation: copy.remediation,
197
+ }));
198
+ }
199
+ const s = readiness.supervisor;
200
+ const g = readiness.github;
201
+ const r = readiness.reconciler;
202
+ const e = readiness.executor;
203
+ const outcomes = [];
204
+ outcomes.push(s.setup_present
205
+ ? { id: "supervisor-setup", status: "pass", detail: `stored (source: ${s.setup_source})` }
206
+ : {
207
+ id: "supervisor-setup",
208
+ status: "fail",
209
+ detail: "no supervisor setup is stored for this repository",
210
+ remediation: SERVER_REMEDIATIONS.supervisorSetup,
211
+ });
212
+ outcomes.push(s.config_present
213
+ ? { id: "supervisor-config", status: "pass", detail: `stored (source: ${s.config_source})` }
214
+ : {
215
+ id: "supervisor-config",
216
+ status: "fail",
217
+ detail: "no supervisor configuration is stored for this repository",
218
+ remediation: SERVER_REMEDIATIONS.supervisorConfig,
219
+ });
220
+ // Counts and booleans are safe facts and stay in `detail`; they never become
221
+ // checks of their own, because there is nothing separate to remediate.
222
+ outcomes.push(s.required_checks_empty
223
+ ? {
224
+ id: "supervisor-required-checks",
225
+ status: "warn",
226
+ detail: "the required-check list is EMPTY — the done gate would pass unconditionally",
227
+ remediation: SERVER_REMEDIATIONS.requiredChecks,
228
+ }
229
+ : {
230
+ id: "supervisor-required-checks",
231
+ status: "pass",
232
+ detail: `${s.required_checks_count} required check(s) configured`,
233
+ });
234
+ // Auto-merge OFF is a posture, not a gap: an operator may deliberately merge
235
+ // by hand. Reported, never remediated.
236
+ outcomes.push({
237
+ id: "auto-merge",
238
+ status: "pass",
239
+ detail: s.auto_merge_enabled ? "auto-merge enabled" : "auto-merge disabled (operator posture)",
240
+ });
241
+ outcomes.push(s.merge_approval_required_set
242
+ ? { id: "merge-approval", status: "pass", detail: "merge-approval requirement is set explicitly" }
243
+ : {
244
+ id: "merge-approval",
245
+ status: "warn",
246
+ detail: "the merge-approval requirement is unset, so the merge path falls back to its default",
247
+ remediation: SERVER_REMEDIATIONS.mergeApproval,
248
+ });
249
+ outcomes.push(reviewPolicyOutcome(readiness));
250
+ outcomes.push(g.credentials_complete
251
+ ? {
252
+ id: "github-credentials",
253
+ status: "pass",
254
+ detail: "owner, repository id, and installation id all resolved",
255
+ }
256
+ : {
257
+ id: "github-credentials",
258
+ status: "fail",
259
+ detail: g.credentials_readable
260
+ ? `incomplete: ${[
261
+ !g.owner_resolved ? "owner" : null,
262
+ !g.repo_id_resolved ? "repository id" : null,
263
+ !g.installation_id_resolved ? "installation id" : null,
264
+ ]
265
+ .filter(Boolean)
266
+ .join(", ")} unresolved`
267
+ : "no GitHub credential row exists for this repository",
268
+ remediation: SERVER_REMEDIATIONS.githubCredentials,
269
+ });
270
+ outcomes.push(githubActionsOutcome(readiness));
271
+ outcomes.push(reconcilerOutcome(readiness));
272
+ outcomes.push(executorOutcome(readiness));
273
+ outcomes.push(reviewWorkflowOutcome(readiness));
274
+ outcomes.push(ciWorkflowOutcome(readiness));
275
+ outcomes.push(...unattendedOutcomes(readiness));
276
+ const byId = new Map(outcomes.map((outcome) => [outcome.id, outcome]));
277
+ return SERVER_READINESS_DESCRIPTORS.map(({ id, label }) => {
278
+ const outcome = byId.get(id);
279
+ if (!outcome) {
280
+ return createReadinessCheck({
281
+ id: `server.${id}`,
282
+ source: "server",
283
+ label,
284
+ status: "fail",
285
+ detail: "this server fact was not reported",
286
+ remediation: SERVER_REMEDIATIONS.olderServer,
287
+ });
288
+ }
289
+ return createReadinessCheckSafely({
290
+ id: `server.${id}`,
291
+ source: "server",
292
+ label,
293
+ status: outcome.status,
294
+ detail: outcome.detail,
295
+ ...(outcome.status === "pass"
296
+ ? {}
297
+ : { remediation: outcome.remediation ?? SERVER_REMEDIATIONS.olderServer }),
298
+ });
299
+ });
300
+ }
301
+ /**
302
+ * The three BAPI-1102 unattended prerequisite rows.
303
+ *
304
+ * Returned as a group because they share one absence rule: a server older than
305
+ * BAPI-1102 reports no `unattended` block at all, and an unreported fact is a
306
+ * FAILURE with the older-server remediation — never a pass. "Could not be read"
307
+ * and "read and healthy" are the two answers this whole module refuses to
308
+ * conflate, and these are prerequisites for an UNATTENDED merge, which is the
309
+ * worst place to guess.
310
+ *
311
+ * Consent and workflow readiness are `warn`, not `fail`, when confirmed absent:
312
+ * a repository that never runs an unattended epic needs neither, so reporting
313
+ * them as failures would mark a perfectly healthy attended install broken. They
314
+ * become hard refusals only at `setup-epic`, and only for a run whose effective
315
+ * policy is actually unattended.
316
+ */
317
+ function unattendedOutcomes(readiness) {
318
+ const u = readiness.unattended;
319
+ if (u === null) {
320
+ return [
321
+ "unattended-consent",
322
+ "unattended-notify-default",
323
+ "repository-workflow-readiness",
324
+ ].map((id) => ({
325
+ id,
326
+ status: "fail",
327
+ detail: "this server fact was not reported",
328
+ remediation: SERVER_REMEDIATIONS.olderServer,
329
+ }));
330
+ }
331
+ return [
332
+ u.conductor_allowed
333
+ ? {
334
+ id: "unattended-consent",
335
+ status: "pass",
336
+ detail: "this repository has consented to unattended conductor runs",
337
+ }
338
+ : {
339
+ id: "unattended-consent",
340
+ status: "warn",
341
+ detail: "unattended_conductor_allowed is NOT set — an unattended run will be refused before it is created",
342
+ remediation: SERVER_REMEDIATIONS.unattendedConsent,
343
+ },
344
+ u.notify_webhook_default_declared
345
+ ? {
346
+ id: "unattended-notify-default",
347
+ status: "pass",
348
+ // Presence only. The URL is never reported by the server and is never
349
+ // rendered here.
350
+ detail: u.notify_webhook_default_verified === true
351
+ ? "a notify webhook default is declared and verified"
352
+ : "a notify webhook default is declared (verified at approval, not here)",
353
+ }
354
+ : {
355
+ id: "unattended-notify-default",
356
+ status: "warn",
357
+ detail: "no repository notify webhook default is declared — an unattended run needs an escalation channel, and no column supplies a repository default today, so the run must declare notify.webhook_url itself",
358
+ remediation: SERVER_REMEDIATIONS.unattendedNotifyDefault,
359
+ },
360
+ u.repository_readiness_confirmed
361
+ ? {
362
+ id: "repository-workflow-readiness",
363
+ status: "pass",
364
+ detail: "the review and conductor-CI workflows are both confirmed",
365
+ }
366
+ : {
367
+ id: "repository-workflow-readiness",
368
+ status: "warn",
369
+ detail: "the review and conductor-CI workflows are not both confirmed — an unattended auto-merging run will be refused",
370
+ remediation: SERVER_REMEDIATIONS.repositoryWorkflowReadiness,
371
+ },
372
+ ];
373
+ }
374
+ /** Review-policy presence and alignment, as one check with one fix each. */
375
+ function reviewPolicyOutcome(readiness) {
376
+ const s = readiness.supervisor;
377
+ const alignment = s.review_policy_alignment;
378
+ if (alignment !== null) {
379
+ if (alignment.status === "divergent" || alignment.status === "invalid") {
380
+ return {
381
+ id: "review-policy",
382
+ status: "warn",
383
+ // The server authors one operator-facing sentence for this; it is a
384
+ // constrained, server-owned explanation, not free-form error text.
385
+ detail: alignment.explanation,
386
+ remediation: SERVER_REMEDIATIONS.reviewPolicyDivergent,
387
+ };
388
+ }
389
+ return { id: "review-policy", status: "pass", detail: alignment.explanation };
390
+ }
391
+ // A pre-alignment server cannot report alignment. Presence is still knowable.
392
+ return s.review_policy_present
393
+ ? {
394
+ id: "review-policy",
395
+ status: "pass",
396
+ detail: "a repository review-policy default is stored (alignment not reported by this server)",
397
+ }
398
+ : {
399
+ id: "review-policy",
400
+ status: "warn",
401
+ detail: "no repository review-policy default is stored",
402
+ remediation: SERVER_REMEDIATIONS.reviewPolicyMissing,
403
+ };
404
+ }
405
+ /**
406
+ * GitHub Actions permission.
407
+ *
408
+ * A FAILED PROBE and a CONFIRMED insufficient permission are two findings with
409
+ * two fixes — retry the probe, versus grant the permission — and collapsing them
410
+ * would send an operator to change a permission that may already be correct.
411
+ */
412
+ function githubActionsOutcome(readiness) {
413
+ const g = readiness.github;
414
+ if (g.actions_write) {
415
+ return { id: "github-actions-permission", status: "pass", detail: "actions: write" };
416
+ }
417
+ if (!g.actions_probe_succeeded) {
418
+ return {
419
+ id: "github-actions-permission",
420
+ status: "fail",
421
+ detail: "the actions permission could not be checked (this is not a confirmed absence)",
422
+ remediation: SERVER_REMEDIATIONS.actionsProbeFailed,
423
+ };
424
+ }
425
+ return {
426
+ id: "github-actions-permission",
427
+ status: "warn",
428
+ detail: `actions: ${g.actions_permission_level}`,
429
+ remediation: SERVER_REMEDIATIONS.actionsMissing,
430
+ };
431
+ }
432
+ /** Reconciler liveness, preserving the server's own `stale` conclusion. */
433
+ function reconcilerOutcome(readiness) {
434
+ const r = readiness.reconciler;
435
+ if (!r.liveness_readable) {
436
+ return {
437
+ id: "reconciler-liveness",
438
+ status: "fail",
439
+ detail: "reconciler liveness could not be read",
440
+ remediation: SERVER_REMEDIATIONS.reconcilerUnreadable,
441
+ };
442
+ }
443
+ if (!r.stale) {
444
+ return {
445
+ id: "reconciler-liveness",
446
+ status: "pass",
447
+ detail: `last tick ${r.last_tick_age_seconds}s ago across ${r.active_run_count} active run(s)`,
448
+ };
449
+ }
450
+ return {
451
+ id: "reconciler-liveness",
452
+ status: "fail",
453
+ detail: r.last_tick_at === null
454
+ ? "no reconciler tick has been recorded for this repository"
455
+ : `last tick ${r.last_tick_age_seconds}s ago (server threshold ${readiness.thresholds.reconciler_stale_after_seconds}s)`,
456
+ remediation: SERVER_REMEDIATIONS.reconcilerStale,
457
+ };
458
+ }
459
+ /**
460
+ * Executor readiness — the AUTHORITATIVE executor fact.
461
+ *
462
+ * The three not-ready shapes stay distinct because they mean different things
463
+ * and need different actions: `null` is "never observed", `false` is "observed
464
+ * but stale", and an unreadable payload is "unknown". Collapsing them would let
465
+ * a stale executor read like a fresh install, or the reverse.
466
+ */
467
+ function executorOutcome(readiness) {
468
+ const e = readiness.executor;
469
+ if (!e.liveness_readable) {
470
+ return {
471
+ id: "executor-readiness",
472
+ status: "fail",
473
+ detail: "executor liveness could not be read",
474
+ remediation: SERVER_REMEDIATIONS.executorUnreadable,
475
+ };
476
+ }
477
+ if (e.ready === true) {
478
+ return {
479
+ id: "executor-readiness",
480
+ status: "pass",
481
+ detail: `executor ready — last seen ${e.last_seen_age_seconds}s ago`,
482
+ };
483
+ }
484
+ if (e.ready === false) {
485
+ return {
486
+ id: "executor-readiness",
487
+ status: "fail",
488
+ detail: `observed but not ready — last seen ${e.last_seen_age_seconds}s ago (server threshold ${readiness.thresholds.executor_stale_after_seconds}s)`,
489
+ remediation: SERVER_REMEDIATIONS.executorStale,
490
+ };
491
+ }
492
+ return {
493
+ id: "executor-readiness",
494
+ status: "fail",
495
+ detail: "never observed by the server",
496
+ remediation: SERVER_REMEDIATIONS.executorNeverSeen,
497
+ };
498
+ }
499
+ /** Review-workflow protocol facts. `null` means an older server, not health. */
500
+ function reviewWorkflowOutcome(readiness) {
501
+ const w = readiness.review_workflow;
502
+ if (w === null) {
503
+ return {
504
+ id: "review-workflow",
505
+ status: "fail",
506
+ detail: "this Bridge API deploy did not report the review-workflow facts",
507
+ remediation: SERVER_REMEDIATIONS.olderServer,
508
+ };
509
+ }
510
+ if (!w.probe_succeeded) {
511
+ return {
512
+ id: "review-workflow",
513
+ status: "fail",
514
+ detail: w.workflow_present
515
+ ? "the review workflow is present but could not be evaluated"
516
+ : "the review workflow could not be found or read on the default branch",
517
+ remediation: SERVER_REMEDIATIONS.reviewWorkflowUnknown,
518
+ };
519
+ }
520
+ if (w.emits_run_id && w.emits_reviewed_sha) {
521
+ return {
522
+ id: "review-workflow",
523
+ status: "pass",
524
+ detail: "the default branch's review workflow emits both protocol markers",
525
+ };
526
+ }
527
+ return {
528
+ id: "review-workflow",
529
+ status: "fail",
530
+ detail: `missing marker(s): ${[
531
+ !w.emits_run_id ? "run id" : null,
532
+ !w.emits_reviewed_sha ? "reviewed SHA" : null,
533
+ ]
534
+ .filter(Boolean)
535
+ .join(", ")}`,
536
+ remediation: SERVER_REMEDIATIONS.reviewWorkflowIncomplete,
537
+ };
538
+ }
539
+ /** Conductor-CI migration-guard facts. `null` means an older server. */
540
+ function ciWorkflowOutcome(readiness) {
541
+ const c = readiness.conductor_ci_workflow;
542
+ if (c === null) {
543
+ return {
544
+ id: "conductor-ci-workflow",
545
+ status: "fail",
546
+ detail: "this Bridge API deploy did not report the conductor-CI workflow facts",
547
+ remediation: SERVER_REMEDIATIONS.olderServer,
548
+ };
549
+ }
550
+ if (!c.probe_succeeded) {
551
+ return {
552
+ id: "conductor-ci-workflow",
553
+ status: "fail",
554
+ detail: c.workflow_present
555
+ ? "the conductor CI workflow is present but could not be evaluated"
556
+ : "the conductor CI workflow could not be found or read on the default branch",
557
+ remediation: SERVER_REMEDIATIONS.ciWorkflowUnknown,
558
+ };
559
+ }
560
+ return c.migration_guard_present
561
+ ? {
562
+ id: "conductor-ci-workflow",
563
+ status: "pass",
564
+ detail: "the default branch's conductor CI workflow runs the migration guard",
565
+ }
566
+ : {
567
+ id: "conductor-ci-workflow",
568
+ status: "warn",
569
+ detail: "the conductor CI workflow does not run the migration guard",
570
+ remediation: SERVER_REMEDIATIONS.ciWorkflowMissingGuard,
571
+ };
572
+ }
573
+ // ---------------------------------------------------------------------------
574
+ // Install-time section adapter
575
+ // ---------------------------------------------------------------------------
576
+ /**
577
+ * Sections the install-time doctor builds that the OWNING collectors also
578
+ * report through their own structured outcomes.
579
+ *
580
+ * Deduplication prefers the owner: a section is a prose interpretation of a
581
+ * probe, while the owning collector's outcome is the probe's own structured
582
+ * result, and reporting both would show one prerequisite twice with two
583
+ * wordings. The install-side ids that overlap are listed rather than matched by
584
+ * heuristic, so adding a section can never silently start shadowing an owner.
585
+ */
586
+ const INSTALL_SECTION_IDS_OWNED_ELSEWHERE = new Set([
587
+ // Server facts — owned by `mapServerReadinessToChecks`.
588
+ "supervisor-configuration",
589
+ "supervisor-required-checks",
590
+ "supervisor-review-alignment",
591
+ "github-credentials",
592
+ "github-actions-permission",
593
+ "reconciler-liveness",
594
+ "executor-observation",
595
+ "server-readiness",
596
+ "bridge-access",
597
+ // Local conductor facts — owned by `mapConductorDoctorReportToReadinessChecks`.
598
+ "conductor-ledger",
599
+ "conductor-ledger-loadability",
600
+ // Legacy embedded reports — owned by their own adapters.
601
+ "install-status",
602
+ ]);
603
+ /** Map the install doctor's own status vocabulary onto the canonical one. */
604
+ function sectionStatus(status) {
605
+ // `fatal` becomes `fail`, and NOTHING else follows from it here: the exit-code
606
+ // consequence stays with the installer's `conductorInstallDoctorHasFatal`.
607
+ if (status === "fatal")
608
+ return "fail";
609
+ return status === "degraded" ? "warn" : "pass";
610
+ }
611
+ /**
612
+ * Project the install-time doctor's remaining sections into `conductor.*` checks.
613
+ *
614
+ * These are the prerequisites no other collector owns — executor provisioning,
615
+ * local service state, the host profile token, hook targets, and the workflow
616
+ * presence section. Ids are prefixed `install-section-` so they cannot collide
617
+ * with a conductor-doctor outcome that happens to share a name.
618
+ */
619
+ export function mapInstallSectionsToReadinessChecks(sections) {
620
+ const checks = [];
621
+ const seen = new Set();
622
+ for (const section of sections ?? []) {
623
+ if (INSTALL_SECTION_IDS_OWNED_ELSEWHERE.has(section.id))
624
+ continue;
625
+ if (seen.has(section.id))
626
+ continue;
627
+ seen.add(section.id);
628
+ const status = sectionStatus(section.status);
629
+ checks.push(createReadinessCheckSafely({
630
+ id: `conductor.install-section-${section.id}`,
631
+ source: "conductor",
632
+ label: section.label,
633
+ status,
634
+ detail: section.detail,
635
+ ...(status === "pass"
636
+ ? {}
637
+ : {
638
+ remediation: section.remediation ??
639
+ "run `install conductor` for this repository to re-provision this capability.",
640
+ }),
641
+ }));
642
+ }
643
+ return checks;
644
+ }
645
+ /**
646
+ * Collect the consolidated advisory readiness report.
647
+ *
648
+ * NEVER THROWS. A collector that fails does not remove its prerequisites from
649
+ * the report — they come back as failed checks with restoring remediations, so a
650
+ * broken source can never make a report look shorter and healthier.
651
+ *
652
+ * The two independent collections (install-time doctor, plane preflight) run
653
+ * concurrently. No retry and no additional timeout is introduced: each collector
654
+ * keeps its own.
655
+ */
656
+ export async function collectConductorReadinessGate(deps) {
657
+ const [installReport, planeResult] = await Promise.all([
658
+ runInstallDoctorSafely(deps),
659
+ runPlaneSafely(deps),
660
+ ]);
661
+ const unavailableSources = [];
662
+ // The install-time doctor is the collector for the install, conductor-section,
663
+ // and server legs; losing it loses all three.
664
+ if (installReport === null) {
665
+ unavailableSources.push("install", "conductor", "server");
666
+ }
667
+ // A plane preflight was requested and could not be run. Not requesting one is
668
+ // not an unavailability — its prerequisites are still reported as unknowns.
669
+ if (deps.runPlanePreflight && planeResult === null)
670
+ unavailableSources.push("plane");
671
+ const checks = [
672
+ ...mapInstallStatusChecksToReadinessChecks((installReport?.legacyInstallChecks ?? null), installReport === null),
673
+ ...mapConductorDoctorReportToReadinessChecks(installReport?.legacyConductor ?? null),
674
+ ...mapInstallSectionsToReadinessChecks(installReport?.sections ?? null),
675
+ ...mapPlanePreflightToReadinessChecks(planeResult),
676
+ ...mapServerReadinessToChecks(installReport?.readiness ?? null, classifyServerFailure(installReport)),
677
+ ];
678
+ // Validate the ASSEMBLED report, not just the pieces: an injected collector or
679
+ // a future adapter could hand over an entry that violates the contract, and a
680
+ // malformed entry becomes a fixed source-scoped failure rather than escaping
681
+ // into output or dropping the locus.
682
+ const validated = validateReadinessChecks(checks);
683
+ return {
684
+ schemaVersion: CONDUCTOR_READINESS_GATE_SCHEMA_VERSION,
685
+ advisory: CONDUCTOR_READINESS_ADVISORY_NOTICE,
686
+ repoName: deps.repoName ?? null,
687
+ unavailableSources,
688
+ checks: validated,
689
+ summary: summarizeReadinessChecks(validated),
690
+ };
691
+ }
692
+ /** Run the install-time doctor, containing any throw. */
693
+ async function runInstallDoctorSafely(deps) {
694
+ try {
695
+ if (deps.runInstallDoctor)
696
+ return await deps.runInstallDoctor();
697
+ if (!deps.installDoctor)
698
+ return null;
699
+ return await runConductorInstallDoctor(deps.conductorDoctorDeps
700
+ ? { ...deps.installDoctor, conductorDoctorDeps: deps.conductorDoctorDeps }
701
+ : deps.installDoctor);
702
+ }
703
+ catch {
704
+ // It is documented never to throw; this contains an injected fake so the
705
+ // whole report is not lost to one collector.
706
+ return null;
707
+ }
708
+ }
709
+ /** Run the plane preflight, containing any throw. */
710
+ async function runPlaneSafely(deps) {
711
+ if (!deps.runPlanePreflight)
712
+ return null;
713
+ try {
714
+ return await deps.runPlanePreflight();
715
+ }
716
+ catch {
717
+ return null;
718
+ }
719
+ }
720
+ /**
721
+ * Why the server facts are missing, when they are.
722
+ *
723
+ * Read from the install doctor's OWN fatal/degraded section rather than
724
+ * re-classified here: it already distinguishes an unresolved identity, a
725
+ * rejected credential, a malformed body, and an unreachable endpoint, and a
726
+ * second classification would be a second chance to disagree with the first.
727
+ */
728
+ function classifyServerFailure(report) {
729
+ if (report === null)
730
+ return "unreachable";
731
+ const sections = report.sections ?? [];
732
+ if (sections.some((section) => section.id === "bridge-access"))
733
+ return "identity-unresolved";
734
+ const serverSection = sections.find((section) => section.id === "server-readiness");
735
+ if (!serverSection)
736
+ return "unreachable";
737
+ if (serverSection.detail.includes("rejected the credential"))
738
+ return "unauthorized";
739
+ if (serverSection.detail.includes("shape validation"))
740
+ return "invalid-response";
741
+ return "unreachable";
742
+ }
743
+ // ---------------------------------------------------------------------------
744
+ // Rendering
745
+ // ---------------------------------------------------------------------------
746
+ /** Status tag, matching the visual hierarchy of the other conductor reports. */
747
+ function readinessTag(status) {
748
+ if (status === "fail")
749
+ return "✗ FAIL";
750
+ if (status === "warn")
751
+ return "! WARN";
752
+ if (status === "skip")
753
+ return "- SKIP";
754
+ return "✓ PASS";
755
+ }
756
+ /**
757
+ * Render the consolidated report for a terminal.
758
+ *
759
+ * Pure formatting. It carries the SAME content the JSON serialization does —
760
+ * every canonical check, its source, its status, its detail, and its
761
+ * remediation — so neither rendering can show an operator a finding the other
762
+ * hides. It computes no overall verdict, because there is none to compute.
763
+ */
764
+ export function formatConductorReadinessGateReport(report) {
765
+ const lines = [
766
+ "Conductor readiness (advisory)",
767
+ "══════════════════════════════",
768
+ `schema version: ${report.schemaVersion}`,
769
+ `repository: ${report.repoName ?? "unresolved"}`,
770
+ "",
771
+ ];
772
+ for (const source of ["install", "conductor", "plane", "server"]) {
773
+ const group = report.checks.filter((check) => check.source === source);
774
+ if (group.length === 0)
775
+ continue;
776
+ lines.push(`[${source}]`);
777
+ for (const check of group) {
778
+ lines.push(` ${readinessTag(check.status)} ${check.label} (${check.id})`);
779
+ if (check.detail)
780
+ lines.push(` ${check.detail}`);
781
+ if (check.remediation)
782
+ lines.push(` → ${check.remediation}`);
783
+ }
784
+ lines.push("");
785
+ }
786
+ const { pass, warn, fail, skip } = report.summary;
787
+ lines.push(`summary: ${pass} pass, ${warn} warn, ${fail} fail, ${skip} skip`);
788
+ if (report.unavailableSources.length > 0) {
789
+ lines.push(`sources that could not be collected: ${report.unavailableSources.join(", ")} ` +
790
+ "(their prerequisites above are reported as failures, not as passes)");
791
+ }
792
+ lines.push("");
793
+ lines.push(report.advisory);
794
+ return lines.join("\n");
795
+ }