@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.
@@ -12,13 +12,71 @@
12
12
  * it — including any manifest belonging to a plane that is already running.
13
13
  */
14
14
  import path from "path";
15
- import { PLANE_SERVER_PORT_ENV_VAR, } from "./types.js";
15
+ import { PLANE_SERVER_PORT_ENV_VAR, planeCheckFinding, planeCheckPassed, } from "./types.js";
16
16
  import { checkPlaneBuildFreshness, checkPlaneRuntimeEntrypoint } from "./build-freshness.js";
17
17
  import { checkAlembicHead } from "./alembic-head.js";
18
18
  import { manifestHasLiveProcess, readPlaneManifest } from "./manifest.js";
19
19
  import { detectClaudeLogin, formatClaudeLoginAdvisory } from "../claude-login.js";
20
+ import { createReadinessCheck, createReadinessCheckSafely, } from "../readiness-check.js";
20
21
  /** Files that must exist for a path to be this repository's root. */
21
22
  const REQUIRED_REPO_FILES = ["main.py", "worker.py", "alembic.ini"];
23
+ /** Fixed remediation for a `plane up` run started outside the repository root. */
24
+ export const PLANE_REPOSITORY_ROOT_REMEDIATION = "run `plane up` from the Bridge API repository root (the directory holding main.py, worker.py, and alembic.ini).";
25
+ /** Fixed remediation for an unresolvable Bridge repository identity. */
26
+ export const PLANE_REPO_IDENTITY_REMEDIATION = "set BAPI_REPO_NAME in this shell, or add a valid .bridge/config at the repository root.";
27
+ /** Fixed remediation for an unresolvable Bridge credential. */
28
+ export const PLANE_CREDENTIAL_REMEDIATION = "set BAPI_API_KEY in this shell, or store it with `mcp-server credentials`; a spawned shell never sees .mcp.json env.";
29
+ /** Fixed remediation for the resolver's credential-file permission advisory. */
30
+ export const PLANE_CREDENTIAL_PERMISSIONS_REMEDIATION = "restrict the Bridge credentials file to owner-only permissions (chmod 600), then rerun.";
31
+ /** Fixed remediation for an advisory Claude-login gap. */
32
+ export const PLANE_CLAUDE_LOGIN_REMEDIATION = "run `claude login` on this host so plane members inherit an authenticated session; startup continues either way.";
33
+ /** Fixed remediation for an occupied local server port. */
34
+ export const PLANE_SERVER_PORT_OCCUPIED_REMEDIATION = `stop the server that owns the port (or wind down its plane) and retry, or set ${PLANE_SERVER_PORT_ENV_VAR} to a free port — the port may belong to a SIBLING WORKTREE.`;
35
+ /** Fixed remediation for a port whose availability could not be determined. */
36
+ export const PLANE_SERVER_PORT_UNKNOWN_REMEDIATION = `retry, or set ${PLANE_SERVER_PORT_ENV_VAR} to a port you know is free; startup continues and uvicorn fails loudly if the port is taken.`;
37
+ /** Fixed remediation for a configured port that could not be resolved at all. */
38
+ export const PLANE_SERVER_ENDPOINT_REMEDIATION = `set ${PLANE_SERVER_PORT_ENV_VAR} to a valid port number (1-65535), or unset it to use the default.`;
39
+ /** Fixed remediation for an unreadable or malformed plane manifest. */
40
+ export const PLANE_MANIFEST_UNREADABLE_REMEDIATION = "inspect .bridge/plane/plane.json and remove it by hand once you have confirmed no plane is running; preflight signalled no process and changed no file.";
41
+ /** Fixed remediation for a plane that is still running. */
42
+ export const PLANE_ALREADY_RUNNING_REMEDIATION = "run `plane status` to inspect the running plane, or `plane down` to wind it down first.";
43
+ /** Ids the credential check reports under, kept stable across every branch. */
44
+ const PLANE_CREDENTIAL_OUTCOME_IDS = {
45
+ identity: "repo-identity",
46
+ credential: "bridge-credentials",
47
+ permissions: "credential-permissions",
48
+ };
49
+ /** Fixed detail for a prerequisite skipped because the repository root is invalid. */
50
+ const PLANE_ROOT_DEPENDENT_SKIP_DETAIL = "not run — every remaining check is relative to a valid repository root";
51
+ /**
52
+ * Every prerequisite `runPlanePreflight` short-circuits past when the
53
+ * repository root is invalid, with the name it is reported under.
54
+ *
55
+ * A table rather than a derived list: these are the checks that must still
56
+ * APPEAR in the outcome set, and deriving them from the ones that ran is exactly
57
+ * the "absence means pass" inference this whole change removes.
58
+ */
59
+ const ROOT_DEPENDENT_CHECKS = [
60
+ ["worktree-presence", "worktree-presence", "Git work tree"],
61
+ ["claude-login", "claude-login", "Claude login"],
62
+ // The credential check reports THREE independently actionable facts under one
63
+ // check name, so all three ids are listed. Listing only the check name would
64
+ // leave `repo-identity` and `credential-permissions` absent from the outcome
65
+ // set, and the adapter would then report them as unknown FAILURES on a run
66
+ // where they were merely never reached.
67
+ ["bridge-credentials", PLANE_CREDENTIAL_OUTCOME_IDS.identity, "Bridge repository identity"],
68
+ ["bridge-credentials", PLANE_CREDENTIAL_OUTCOME_IDS.credential, "Bridge API credential"],
69
+ [
70
+ "bridge-credentials",
71
+ PLANE_CREDENTIAL_OUTCOME_IDS.permissions,
72
+ "Bridge credential file permissions",
73
+ ],
74
+ ["executor-build", "executor-build", "Executor build freshness"],
75
+ ["runtime-entrypoint", "runtime-entrypoint", "Runtime re-exec entrypoint"],
76
+ ["server-port", "server-port", "Local server port"],
77
+ ["alembic-head", "alembic-head", "Database migration head"],
78
+ ["existing-plane", "existing-plane", "Existing plane"],
79
+ ];
22
80
  /** Bound on the port probe so a black-holed port cannot stall bring-up. */
23
81
  export const PLANE_PORT_PROBE_TIMEOUT_MS = 1_500;
24
82
  /**
@@ -31,25 +89,50 @@ export const PLANE_PORT_PROBE_TIMEOUT_MS = 1_500;
31
89
  */
32
90
  export async function runPlanePreflight(repoRoot, deps) {
33
91
  const diagnostics = [];
92
+ const outcomes = [];
34
93
  const add = (diagnostic) => {
35
94
  if (diagnostic)
36
95
  diagnostics.push(diagnostic);
37
96
  };
38
- const rootCheck = await checkRepositoryRoot(repoRoot, deps);
39
- add(rootCheck);
97
+ // BAPI-1055: every check's own report, recorded whole. `add` still filters
98
+ // nulls for the legacy diagnostic list; `record` never infers anything from
99
+ // that filtering — the outcomes come from the check itself.
100
+ const record = (report) => {
101
+ add(report.diagnostic);
102
+ outcomes.push(...report.outcomes);
103
+ return report;
104
+ };
105
+ const rootCheck = record(await checkRepositoryRoot(repoRoot, deps));
40
106
  // Every remaining check is relative to the repository root. Without a valid
41
107
  // one there is nothing coherent left to check, so report and stop here rather
42
108
  // than emitting a cascade of derived failures the operator cannot act on.
43
- if (rootCheck)
44
- return { ok: false, diagnostics };
109
+ if (rootCheck.diagnostic) {
110
+ // The dependent prerequisites are recorded as explicitly SKIPPED rather
111
+ // than left absent (BAPI-1055). No probe is issued for any of them — no
112
+ // filesystem read, no process spawn, no credential resolution, no port
113
+ // connect — so the read-only, no-side-effect contract is unchanged; only
114
+ // the reporting is. Without this, a run that checked the least would look
115
+ // identical to a run in which everything passed.
116
+ for (const [check, id, label] of ROOT_DEPENDENT_CHECKS) {
117
+ outcomes.push({
118
+ check,
119
+ id,
120
+ label,
121
+ status: "skip",
122
+ detail: PLANE_ROOT_DEPENDENT_SKIP_DETAIL,
123
+ });
124
+ }
125
+ return { ok: false, diagnostics, outcomes };
126
+ }
45
127
  // BAPI-1054: aggregated with every other independent check rather than
46
128
  // short-circuiting. `checkRepositoryRoot` above proved the marker files are
47
129
  // here; this proves the directory is a real work tree. They are independent
48
130
  // facts and both can be true at once, which is exactly what the all-or-nothing
49
131
  // contract exists to report together.
50
- add(await checkRepositoryWorktree(repoRoot, deps));
51
- add(await checkClaudeLogin(deps));
132
+ record(await checkRepositoryWorktree(repoRoot, deps));
133
+ record(await checkClaudeLogin(deps));
52
134
  const credentials = await checkBridgeCredentials(repoRoot, deps);
135
+ outcomes.push(...credentials.outcomes);
53
136
  // Advisories first, then the failure. An insecure credentials file and an
54
137
  // unresolvable credential are independent facts and can both be true, so the
55
138
  // advisory is appended unconditionally rather than only on the success path.
@@ -57,26 +140,33 @@ export async function runPlanePreflight(repoRoot, deps) {
57
140
  add(advisory);
58
141
  if (!credentials.ok)
59
142
  add(credentials.diagnostic);
60
- add(await checkPlaneBuildFreshness(repoRoot, { fs: deps.fs }));
143
+ record(await checkPlaneBuildFreshness(repoRoot, { fs: deps.fs }));
61
144
  // Read-only, and evaluated alongside the other independent checks so an
62
145
  // unresolvable re-exec target aggregates with them instead of short-circuiting.
63
146
  const runtimeEntrypoint = deps.resolveRuntimeEntrypoint();
64
- add(checkPlaneRuntimeEntrypoint(runtimeEntrypoint));
147
+ record(checkPlaneRuntimeEntrypoint(runtimeEntrypoint));
65
148
  // An unresolvable override is a configuration failure, not a port failure:
66
149
  // there is no port to probe, so the probe is skipped entirely rather than
67
150
  // falling back to 8000 and reporting on a port the operator did not ask for.
68
151
  if (deps.endpoint.ok) {
69
- add(await checkServerPort(deps, deps.endpoint.endpoint));
152
+ record(await checkServerPort(deps, deps.endpoint.endpoint));
70
153
  }
71
154
  else {
72
- add({ check: "server-port", severity: "blocking", message: deps.endpoint.message });
155
+ // The endpoint resolver owns this diagnostic's prose; the structured fix is
156
+ // authored here beside it rather than derived downstream by an aggregator.
157
+ record(planeCheckFinding({
158
+ check: "server-port",
159
+ severity: "blocking",
160
+ message: deps.endpoint.message,
161
+ remediation: PLANE_SERVER_ENDPOINT_REMEDIATION,
162
+ }, "Local server port", "the configured local server endpoint could not be resolved"));
73
163
  }
74
- add(await checkAlembicHead(repoRoot, {
164
+ record(await checkAlembicHead(repoRoot, {
75
165
  execFile: deps.execFile,
76
166
  fileExists: (filePath) => fileExists(filePath, deps.fs),
77
167
  platform: deps.platform,
78
168
  }));
79
- add(await checkExistingPlane(repoRoot, deps));
169
+ record(await checkExistingPlane(repoRoot, deps));
80
170
  const blocking = diagnostics.filter((d) => d.severity === "blocking");
81
171
  // `!runtimeEntrypoint.ok` is already covered by the blocking count; it is
82
172
  // repeated here so the compiler narrows the union rather than requiring a
@@ -85,11 +175,12 @@ export async function runPlanePreflight(repoRoot, deps) {
85
175
  // here, like `!runtimeEntrypoint.ok`, so the compiler narrows the union rather
86
176
  // than requiring a non-null assertion on the context field below.
87
177
  if (blocking.length > 0 || !credentials.ok || !runtimeEntrypoint.ok || !deps.endpoint.ok) {
88
- return { ok: false, diagnostics };
178
+ return { ok: false, diagnostics, outcomes };
89
179
  }
90
180
  return {
91
181
  ok: true,
92
182
  diagnostics,
183
+ outcomes,
93
184
  context: {
94
185
  repoRoot,
95
186
  repoName: credentials.repoName,
@@ -116,12 +207,14 @@ export async function runPlanePreflight(repoRoot, deps) {
116
207
  * parent would otherwise spawn `uvicorn main:app` against nothing.
117
208
  */
118
209
  async function checkRepositoryRoot(repoRoot, deps) {
210
+ const label = "Repository root";
119
211
  if (!path.isAbsolute(repoRoot)) {
120
- return {
212
+ return planeCheckFinding({
121
213
  check: "repository-root",
122
214
  severity: "blocking",
123
215
  message: "the repository root could not be resolved to an absolute path",
124
- };
216
+ remediation: PLANE_REPOSITORY_ROOT_REMEDIATION,
217
+ }, label, "the repository root could not be resolved to an absolute path");
125
218
  }
126
219
  const missing = [];
127
220
  for (const file of REQUIRED_REPO_FILES) {
@@ -131,14 +224,18 @@ async function checkRepositoryRoot(repoRoot, deps) {
131
224
  if (!(await fileExists(path.join(repoRoot, "mcp_server"), deps.fs))) {
132
225
  missing.push("mcp_server/");
133
226
  }
134
- if (missing.length === 0)
135
- return null;
136
- return {
227
+ if (missing.length === 0) {
228
+ return planeCheckPassed("repository-root", label, "all Bridge API root marker files are present");
229
+ }
230
+ return planeCheckFinding({
137
231
  check: "repository-root",
138
232
  severity: "blocking",
139
233
  message: `this does not look like the Bridge API repository root — missing ${missing.join(", ")}. ` +
140
234
  "Run `plane up` from the repository root.",
141
- };
235
+ remediation: PLANE_REPOSITORY_ROOT_REMEDIATION,
236
+ }, label,
237
+ // Marker file NAMES only — never the resolved root, which is an absolute path.
238
+ `missing root marker(s): ${missing.join(", ")}`);
142
239
  }
143
240
  /** Remediation for a directory that is not a Git work tree. Fixed prose. */
144
241
  export const PLANE_WORKTREE_REMEDIATION = "cd into the intended worktree (or create it with `git worktree add`) and rerun.";
@@ -160,6 +257,7 @@ export const PLANE_WORKTREE_REMEDIATION = "cd into the intended worktree (or cre
160
257
  * operator performs it.
161
258
  */
162
259
  export async function checkRepositoryWorktree(repoRoot, deps) {
260
+ const label = "Git work tree";
163
261
  const result = await deps.execFile("git", ["rev-parse", "--is-inside-work-tree"], {
164
262
  cwd: repoRoot,
165
263
  });
@@ -168,14 +266,19 @@ export async function checkRepositoryWorktree(repoRoot, deps) {
168
266
  // stderr is deliberately NOT interpolated: it is unbounded text from a
169
267
  // subprocess, and the remediation does not depend on which way it failed.
170
268
  if (!result.ok || result.stdout.trim() !== "true") {
171
- return {
269
+ return planeCheckFinding({
172
270
  check: "worktree-presence",
173
271
  severity: "blocking",
174
272
  message: `${repoRoot} is not inside a Git work tree (git rev-parse --is-inside-work-tree ` +
175
273
  `did not report 'true'). No worktree was created — ${PLANE_WORKTREE_REMEDIATION}`,
176
- };
274
+ remediation: PLANE_WORKTREE_REMEDIATION,
275
+ }, label,
276
+ // The legacy `message` keeps naming `repoRoot` for the operator reading a
277
+ // terminal; the consolidated detail excludes it, because that root is an
278
+ // absolute path and the consolidated report is a wider surface.
279
+ "this directory is not inside a Git work tree");
177
280
  }
178
- return null;
281
+ return planeCheckPassed("worktree-presence", label, "inside a Git work tree");
179
282
  }
180
283
  /**
181
284
  * Advisory-only Claude login marker (BAPI-791).
@@ -191,14 +294,41 @@ export async function checkRepositoryWorktree(repoRoot, deps) {
191
294
  * credential check `plane up` performs.
192
295
  */
193
296
  export async function checkClaudeLogin(deps) {
297
+ const label = "Claude login";
194
298
  const result = await detectClaudeLogin({ homedir: deps.homedir, readFile: deps.fs.readFile });
195
- if (result.detected)
196
- return null;
197
- return {
299
+ if (result.detected) {
300
+ return planeCheckPassed("claude-login", label, "a local Claude login marker was detected");
301
+ }
302
+ // Still a WARNING, never blocking: the advisory gains a structured fix, it
303
+ // does not gain the power to refuse a bring-up.
304
+ return planeCheckFinding({
198
305
  check: "claude-login",
199
306
  severity: "warning",
200
307
  message: formatClaudeLoginAdvisory(result),
201
- };
308
+ remediation: PLANE_CLAUDE_LOGIN_REMEDIATION,
309
+ }, label, "no local Claude login marker was detected");
310
+ }
311
+ /** Build the credential check's permission-advisory outcome. */
312
+ function credentialPermissionsOutcome(advisories) {
313
+ // Counted, never quoted: the resolver's advisory text is buffered output this
314
+ // module does not author, so the consolidated outcome reports only that it
315
+ // fired. The advisory itself still reaches the operator as a diagnostic.
316
+ return advisories.length === 0
317
+ ? {
318
+ check: "bridge-credentials",
319
+ id: PLANE_CREDENTIAL_OUTCOME_IDS.permissions,
320
+ label: "Bridge credential file permissions",
321
+ status: "pass",
322
+ detail: "the credential resolver raised no permission advisory",
323
+ }
324
+ : {
325
+ check: "bridge-credentials",
326
+ id: PLANE_CREDENTIAL_OUTCOME_IDS.permissions,
327
+ label: "Bridge credential file permissions",
328
+ status: "warn",
329
+ detail: `the credential resolver raised ${advisories.length} permission advisory line(s)`,
330
+ remediation: PLANE_CREDENTIAL_PERMISSIONS_REMEDIATION,
331
+ };
202
332
  }
203
333
  /**
204
334
  * Resolve the repository identity, then the Bridge credential for it.
@@ -233,7 +363,8 @@ export async function checkBridgeCredentials(repoRoot, deps) {
233
363
  });
234
364
  if (!repo.ok) {
235
365
  // Nothing has called the credential resolver yet, so there is no advisory
236
- // source to have captured from.
366
+ // source to have captured from — and the credential outcome is `skip`
367
+ // rather than `fail`, because the resolver was never asked.
237
368
  return {
238
369
  ok: false,
239
370
  advisories: [],
@@ -242,7 +373,26 @@ export async function checkBridgeCredentials(repoRoot, deps) {
242
373
  severity: "blocking",
243
374
  message: "the Bridge repository identity could not be resolved — set BAPI_REPO_NAME or add a " +
244
375
  "valid .bridge/config at the repository root.",
376
+ remediation: PLANE_REPO_IDENTITY_REMEDIATION,
245
377
  },
378
+ outcomes: [
379
+ {
380
+ check: "bridge-credentials",
381
+ id: PLANE_CREDENTIAL_OUTCOME_IDS.identity,
382
+ label: "Bridge repository identity",
383
+ status: "fail",
384
+ detail: "the Bridge repository identity could not be resolved",
385
+ remediation: PLANE_REPO_IDENTITY_REMEDIATION,
386
+ },
387
+ {
388
+ check: "bridge-credentials",
389
+ id: PLANE_CREDENTIAL_OUTCOME_IDS.credential,
390
+ label: "Bridge API credential",
391
+ status: "skip",
392
+ detail: "not resolved — there is no repository identity to resolve a credential for",
393
+ },
394
+ credentialPermissionsOutcome([]),
395
+ ],
246
396
  };
247
397
  }
248
398
  const warnings = createResolverWarningBuffer();
@@ -267,36 +417,92 @@ export async function checkBridgeCredentials(repoRoot, deps) {
267
417
  // Sanitized: a resolver throw becomes a category, never exception text
268
418
  // (which could echo file contents). Advisories captured before the throw
269
419
  // are still real observations and are kept.
420
+ const advisories = warnings.drain();
270
421
  return {
271
422
  ok: false,
272
- advisories: warnings.drain(),
423
+ advisories,
273
424
  diagnostic: {
274
425
  check: "bridge-credentials",
275
426
  severity: "blocking",
276
427
  message: `Bridge credentials for target bapi:${repo.repoName} could not be resolved ` +
277
428
  "(resolver unavailable).",
429
+ remediation: PLANE_CREDENTIAL_REMEDIATION,
278
430
  },
431
+ outcomes: [
432
+ identityResolvedOutcome(),
433
+ {
434
+ check: "bridge-credentials",
435
+ id: PLANE_CREDENTIAL_OUTCOME_IDS.credential,
436
+ label: "Bridge API credential",
437
+ status: "fail",
438
+ // Category, never exception text: a resolver throw can echo file content.
439
+ detail: "the credential resolver was unavailable",
440
+ remediation: PLANE_CREDENTIAL_REMEDIATION,
441
+ },
442
+ credentialPermissionsOutcome(advisories),
443
+ ],
279
444
  };
280
445
  }
281
446
  if (!result.ok) {
447
+ const advisories = warnings.drain();
282
448
  return {
283
449
  ok: false,
284
- advisories: warnings.drain(),
450
+ advisories,
285
451
  diagnostic: {
286
452
  check: "bridge-credentials",
287
453
  severity: "blocking",
288
454
  message: `Bridge credentials for target bapi:${repo.repoName} could not be resolved ` +
289
455
  `(${result.kind}). Set BAPI_API_KEY in this shell, or store it with ` +
290
456
  "`mcp-server credentials`. A spawned shell never sees .mcp.json env.",
457
+ remediation: PLANE_CREDENTIAL_REMEDIATION,
291
458
  },
459
+ outcomes: [
460
+ identityResolvedOutcome(),
461
+ {
462
+ check: "bridge-credentials",
463
+ id: PLANE_CREDENTIAL_OUTCOME_IDS.credential,
464
+ label: "Bridge API credential",
465
+ status: "fail",
466
+ // `result.kind` is the resolver's own closed category vocabulary; the
467
+ // repository name is deliberately omitted from the consolidated detail.
468
+ detail: `no Bridge API credential resolved (${result.kind})`,
469
+ remediation: PLANE_CREDENTIAL_REMEDIATION,
470
+ },
471
+ credentialPermissionsOutcome(advisories),
472
+ ],
292
473
  };
293
474
  }
475
+ const advisories = warnings.drain();
294
476
  return {
295
477
  ok: true,
296
478
  repoName: repo.repoName,
297
479
  apiKey: result.credentials.apiKey,
298
480
  source: result.credentials.source,
299
- advisories: warnings.drain(),
481
+ advisories,
482
+ outcomes: [
483
+ identityResolvedOutcome(),
484
+ {
485
+ check: "bridge-credentials",
486
+ id: PLANE_CREDENTIAL_OUTCOME_IDS.credential,
487
+ label: "Bridge API credential",
488
+ status: "pass",
489
+ // The SOURCE, never the value: `source` is `"env"` or `"file"`.
490
+ detail: `resolved from ${result.credentials.source} (the value is never read into a report)`,
491
+ },
492
+ credentialPermissionsOutcome(advisories),
493
+ ],
494
+ };
495
+ }
496
+ /** The identity outcome for every branch reached after identity resolved. */
497
+ function identityResolvedOutcome() {
498
+ return {
499
+ check: "bridge-credentials",
500
+ id: PLANE_CREDENTIAL_OUTCOME_IDS.identity,
501
+ label: "Bridge repository identity",
502
+ status: "pass",
503
+ // The resolved repository NAME is omitted deliberately: it is not needed to
504
+ // act on a passing check, and this outcome flows into a wider report.
505
+ detail: "the Bridge repository identity resolved",
300
506
  };
301
507
  }
302
508
  /**
@@ -353,26 +559,35 @@ function createResolverWarningBuffer() {
353
559
  * refuse a perfectly good bring-up.
354
560
  */
355
561
  export async function checkServerPort(deps, endpoint) {
562
+ const label = "Local server port";
356
563
  const target = `${endpoint.host}:${endpoint.port}`;
357
564
  const result = await deps.probePort(endpoint.host, endpoint.port, PLANE_PORT_PROBE_TIMEOUT_MS);
358
- if (result.kind === "refused")
359
- return null;
565
+ if (result.kind === "refused") {
566
+ return planeCheckPassed("server-port", label, `${target} is free`);
567
+ }
568
+ // Severity is unchanged in both branches: `connected` still blocks, an
569
+ // indeterminate probe still only warns.
360
570
  if (result.kind === "connected") {
361
- return {
571
+ return planeCheckFinding({
362
572
  check: "server-port",
363
573
  severity: "blocking",
364
574
  message: `${target} is already accepting connections. ` +
365
575
  "That port may belong to a SIBLING WORKTREE's server — check before you kill it. " +
366
576
  `Stop the existing server (or wind down its plane) and retry, or set ${PLANE_SERVER_PORT_ENV_VAR} ` +
367
577
  "to a free port.",
368
- };
578
+ remediation: PLANE_SERVER_PORT_OCCUPIED_REMEDIATION,
579
+ }, label, `${target} is already accepting connections`);
369
580
  }
370
- return {
581
+ return planeCheckFinding({
371
582
  check: "server-port",
372
583
  severity: "warning",
373
584
  message: `could not determine whether ${target} is free ` +
374
585
  `(${result.error}); startup continues and uvicorn will fail loudly if the port is taken.`,
375
- };
586
+ remediation: PLANE_SERVER_PORT_UNKNOWN_REMEDIATION,
587
+ }, label,
588
+ // The probe's own `error` text stays in the legacy message for the operator
589
+ // at the terminal and is not copied into the consolidated detail.
590
+ `could not determine whether ${target} is free`);
376
591
  }
377
592
  /**
378
593
  * Refuse to start over a plane that is still alive.
@@ -382,17 +597,23 @@ export async function checkServerPort(deps, endpoint) {
382
597
  * where the classification can be re-checked. Preflight never mutates disk.
383
598
  */
384
599
  export async function checkExistingPlane(repoRoot, deps) {
600
+ const label = "Existing plane";
385
601
  const read = await readPlaneManifest(repoRoot, deps.fs);
386
- if (read.kind === "missing")
387
- return null;
602
+ // Three distinct states, three distinct fixes (BAPI-1055): unreadable/malformed,
603
+ // stale, and live. Manifest validation, the liveness probe, and the
604
+ // never-touch-disk rule are all unchanged.
605
+ if (read.kind === "missing") {
606
+ return planeCheckPassed("existing-plane", label, "no plane manifest is present");
607
+ }
388
608
  if (read.kind !== "valid") {
389
- return {
609
+ return planeCheckFinding({
390
610
  check: "existing-plane",
391
611
  severity: "blocking",
392
612
  message: `an existing .bridge/plane/plane.json could not be validated (${read.error}). ` +
393
613
  "No process was signalled and the file was left untouched — inspect it, then remove " +
394
614
  "it by hand if no plane is running.",
395
- };
615
+ remediation: PLANE_MANIFEST_UNREADABLE_REMEDIATION,
616
+ }, label, "an existing plane manifest could not be validated");
396
617
  }
397
618
  // BAPI-882: a manifest carrying THIS process's own plane id is its own claim,
398
619
  // not a competitor. Checked after validation and BEFORE the liveness probe,
@@ -411,24 +632,40 @@ export async function checkExistingPlane(repoRoot, deps) {
411
632
  // A live manifest with a DIFFERENT plane id still blocks, unchanged — that is
412
633
  // a genuine second plane and the whole point of the check.
413
634
  if (deps.ownPlaneId !== undefined && read.manifest.planeId === deps.ownPlaneId) {
414
- return null;
635
+ return planeCheckPassed("existing-plane", label, "the existing manifest carries this launch's own plane id");
415
636
  }
416
637
  if (!manifestHasLiveProcess(read.manifest, deps.proc)) {
417
638
  // Stale. Reported as information so the operator understands why an old
418
639
  // manifest is about to be replaced; the claim revalidates before it does.
640
+ // No remediation: the operator has nothing to do — the claim replaces it.
419
641
  return {
420
- check: "existing-plane",
421
- severity: "warning",
422
- message: "a previous plane manifest is present but every recorded process is gone; it will be " +
423
- "replaced after a final liveness re-check.",
642
+ diagnostic: {
643
+ check: "existing-plane",
644
+ severity: "warning",
645
+ message: "a previous plane manifest is present but every recorded process is gone; it will be " +
646
+ "replaced after a final liveness re-check.",
647
+ },
648
+ outcomes: [
649
+ {
650
+ check: "existing-plane",
651
+ id: "existing-plane",
652
+ label,
653
+ status: "warn",
654
+ detail: "a stale plane manifest is present and will be replaced after a liveness re-check",
655
+ },
656
+ ],
424
657
  };
425
658
  }
426
- return {
659
+ return planeCheckFinding({
427
660
  check: "existing-plane",
428
661
  severity: "blocking",
429
662
  message: `a plane is already running (supervisor pid ${read.manifest.supervisorPid}). ` +
430
663
  "Run `plane status` to inspect it, or `plane down` to wind it down first.",
431
- };
664
+ remediation: PLANE_ALREADY_RUNNING_REMEDIATION,
665
+ }, label,
666
+ // The supervisor pid stays in the legacy message; the consolidated detail
667
+ // reports the fact, not the process identifier.
668
+ "a plane is already running for this repository");
432
669
  }
433
670
  async function fileExists(filePath, fs) {
434
671
  try {
@@ -439,3 +676,81 @@ async function fileExists(filePath, fs) {
439
676
  return false;
440
677
  }
441
678
  }
679
+ // ---------------------------------------------------------------------------
680
+ // Canonical readiness projection (BAPI-1055)
681
+ // ---------------------------------------------------------------------------
682
+ /**
683
+ * The complete plane prerequisite set, keyed by outcome id, in render order.
684
+ *
685
+ * Stable like the install- and conductor-side descriptor sets, and for the same
686
+ * reason: a preflight that returned early, threw, or was never run must still
687
+ * yield every prerequisite as an explicit unknown.
688
+ */
689
+ export const PLANE_READINESS_DESCRIPTORS = [
690
+ { id: "repository-root", label: "Repository root" },
691
+ { id: "worktree-presence", label: "Git work tree" },
692
+ { id: "claude-login", label: "Claude login" },
693
+ { id: "repo-identity", label: "Bridge repository identity" },
694
+ { id: "bridge-credentials", label: "Bridge API credential" },
695
+ { id: "credential-permissions", label: "Bridge credential file permissions" },
696
+ { id: "executor-build", label: "Executor build freshness" },
697
+ { id: "runtime-entrypoint", label: "Runtime re-exec entrypoint" },
698
+ { id: "server-port", label: "Local server port" },
699
+ { id: "alembic-head", label: "Database migration head" },
700
+ { id: "existing-plane", label: "Existing plane" },
701
+ ];
702
+ /** Fixed detail for a plane prerequisite the preflight never reported on. */
703
+ const PLANE_UNREPORTED_DETAIL = "not reported — the plane preflight produced no outcome for this prerequisite";
704
+ /** Fixed remediation for a plane prerequisite the preflight never reported on. */
705
+ const PLANE_UNREPORTED_REMEDIATION = "run `plane up` (or `conductor readiness` again) to re-collect the plane preflight; this " +
706
+ "prerequisite's state is unknown, not healthy.";
707
+ /**
708
+ * Project a plane preflight result into canonical readiness checks.
709
+ *
710
+ * PURE, and it NEVER runs a preflight of its own: it reads the outcomes an
711
+ * already-executed `runPlanePreflight` emitted. Two rules matter here and both
712
+ * exist because of how this could quietly go wrong:
713
+ *
714
+ * - A pass is only ever read from an explicit `status: "pass"` outcome. The
715
+ * absence of a `PlaneDiagnostic` proves nothing — `add()` keeps only non-null
716
+ * diagnostics, and a failed repository-root check returns before most checks
717
+ * run — so a report built on silence would look healthiest on the runs where
718
+ * the least was actually checked.
719
+ * - A launch-permitting `warning` maps to `warn`, never `fail` and never `skip`,
720
+ * and nothing here feeds back into the preflight's own `ok` calculation.
721
+ */
722
+ export function mapPlanePreflightToReadinessChecks(result) {
723
+ const byId = new Map();
724
+ for (const outcome of result?.outcomes ?? []) {
725
+ if (!byId.has(outcome.id))
726
+ byId.set(outcome.id, outcome);
727
+ }
728
+ return PLANE_READINESS_DESCRIPTORS.map(({ id, label }) => {
729
+ const outcome = byId.get(id);
730
+ if (!outcome) {
731
+ return createReadinessCheck({
732
+ id: `plane.${id}`,
733
+ source: "plane",
734
+ label,
735
+ status: "fail",
736
+ detail: PLANE_UNREPORTED_DETAIL,
737
+ remediation: PLANE_UNREPORTED_REMEDIATION,
738
+ });
739
+ }
740
+ return createReadinessCheckSafely({
741
+ id: `plane.${id}`,
742
+ source: "plane",
743
+ label: outcome.label || label,
744
+ status: outcome.status,
745
+ ...(outcome.detail ? { detail: outcome.detail } : {}),
746
+ // A failure without a fix would violate the contract and be replaced by a
747
+ // generic "unreadable" check, losing the real finding; the fallback keeps
748
+ // the finding and names the recollection step instead.
749
+ ...(outcome.status === "fail"
750
+ ? { remediation: outcome.remediation ?? PLANE_UNREPORTED_REMEDIATION }
751
+ : outcome.status !== "pass" && outcome.remediation
752
+ ? { remediation: outcome.remediation }
753
+ : {}),
754
+ });
755
+ });
756
+ }