@bridge_gpt/mcp-server 0.2.69 → 0.2.70

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 (151) hide show
  1. package/README.md +140 -292
  2. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  3. package/build/agents.generated.js +4 -4
  4. package/build/artifact-diff.js +110 -0
  5. package/build/bin-entrypoint.js +36 -0
  6. package/build/bridge-permissions.js +23 -4
  7. package/build/claude-review-workflow-drift.js +3 -3
  8. package/build/codex-bridge-profile.js +1 -1
  9. package/build/commands.generated.js +6 -6
  10. package/build/conductor/bridge-api-client.js +598 -19
  11. package/build/conductor/bring-up-facts.js +9 -9
  12. package/build/conductor/doctor.js +466 -18
  13. package/build/conductor/epic-child-continuation.js +54 -3
  14. package/build/conductor/epic-implementer-cli.js +132 -34
  15. package/build/conductor/epic-runtime.js +2 -2
  16. package/build/conductor/epic-state.js +1 -1
  17. package/build/conductor/hook-runtime-qualification.js +1 -1
  18. package/build/conductor/install-doctor.js +4 -2
  19. package/build/conductor/launch-need-probes.js +337 -0
  20. package/build/conductor/local-merge.js +241 -56
  21. package/build/conductor/merge-credential.js +172 -0
  22. package/build/conductor/merge-execution-eligibility.js +1 -1
  23. package/build/conductor/merged-child-check.js +95 -0
  24. package/build/conductor/plan-trim.js +30 -0
  25. package/build/conductor/pr-ci-producer.js +1 -1
  26. package/build/conductor/readiness-cli.js +11 -6
  27. package/build/conductor/readiness-prerequisites.js +41 -0
  28. package/build/conductor/readiness-sections.js +345 -55
  29. package/build/conductor/readiness.js +99 -87
  30. package/build/conductor/recovery-cli.js +606 -21
  31. package/build/conductor/recovery-operations.js +306 -7
  32. package/build/conductor/repair-contract.js +47 -2
  33. package/build/conductor/repository-workflow-remedy.js +8 -6
  34. package/build/conductor/stored-merge-credential.js +76 -0
  35. package/build/conductor/stored-merge-token-probe.js +28 -0
  36. package/build/conductor/temporary-plan.js +50 -0
  37. package/build/conductor/wait-run-cli.js +759 -0
  38. package/build/conductor/worker-database-doctor-inspector.js +49 -0
  39. package/build/conductor-bin.js +2 -2
  40. package/build/conductor-claude-hook-bin.js +2 -2
  41. package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
  42. package/build/conductor-removed-stub-bin.js +2 -5
  43. package/build/credential-store.js +356 -13
  44. package/build/docs.generated.js +26 -13
  45. package/build/doctor.js +94 -32
  46. package/build/drive-epic-help.js +212 -0
  47. package/build/drive-epic.js +1840 -373
  48. package/build/epic-ci-workflow-drift.js +77 -0
  49. package/build/epic-ci-workflow-install.js +183 -0
  50. package/build/epic-ci-workflow.js +157 -0
  51. package/build/epic-contract-context.js +326 -0
  52. package/build/epic-implementer-bin.js +57 -41
  53. package/build/epic-implementer-claude-hook-bin.js +2 -2
  54. package/build/escalation-reachability.js +134 -0
  55. package/build/executor/cleanup-report.js +99 -0
  56. package/build/executor/cli.js +12 -1
  57. package/build/executor/completion-contract.js +66 -11
  58. package/build/executor/completion-supervision.js +99 -0
  59. package/build/executor/delivery-outcome.js +179 -0
  60. package/build/executor/deps.js +3 -0
  61. package/build/executor/env.js +71 -0
  62. package/build/executor/failure-reason-codes.js +126 -0
  63. package/build/executor/heartbeat.js +38 -0
  64. package/build/executor/http-client.js +72 -0
  65. package/build/executor/integration-job.js +298 -5
  66. package/build/executor/job-errors.js +36 -6
  67. package/build/executor/job-runner.js +720 -36
  68. package/build/executor/likely-tests.js +36 -2
  69. package/build/executor/merge-job.js +145 -2
  70. package/build/executor/observation.js +73 -4
  71. package/build/executor/permissions.js +193 -97
  72. package/build/executor/preflight.js +14 -3
  73. package/build/executor/process-containment.js +28 -2
  74. package/build/executor/prompt-spec.js +50 -5
  75. package/build/executor/rebase-finalization.js +38 -3
  76. package/build/executor/reconcile-overlap-job.js +35 -7
  77. package/build/executor/required-work-ledger.js +236 -81
  78. package/build/executor/resume-pre-spawn.js +38 -1
  79. package/build/executor/runner.js +91 -3
  80. package/build/executor/test-clock.js +8 -0
  81. package/build/executor/types.js +37 -2
  82. package/build/executor/worker-cli-launcher.js +259 -0
  83. package/build/executor/worker-command.js +41 -4
  84. package/build/executor/worker-database-target.js +91 -0
  85. package/build/executor/worker-database.js +908 -0
  86. package/build/executor/worker-finalization.js +106 -0
  87. package/build/executor/worker-guard-hook.js +203 -0
  88. package/build/executor/worker-resources.js +47 -0
  89. package/build/executor/worker-verification-guidance.js +15 -2
  90. package/build/executor/worktree.js +303 -15
  91. package/build/executor-launch.js +11 -1
  92. package/build/generated-assets-doctor.js +9 -1
  93. package/build/generated-assets-health.js +13 -4
  94. package/build/generated-assets-legacy.js +39 -0
  95. package/build/generated-assets-paths.js +12 -1
  96. package/build/generated-assets-repair.js +4 -1
  97. package/build/generated-assets-test-support.js +12 -0
  98. package/build/github-remote.js +33 -0
  99. package/build/github-sign-in.js +62 -0
  100. package/build/index.js +1067 -536
  101. package/build/init.js +39 -1
  102. package/build/install-bridge-conductor-gate.js +78 -0
  103. package/build/install-bridge-conductor-setup.js +413 -0
  104. package/build/install-bridge-conductor.js +1226 -267
  105. package/build/install-bridge.js +376 -60
  106. package/build/mcp-install-state.js +8 -2
  107. package/build/merge-pull-request.js +6 -0
  108. package/build/merge-workflow-install.js +763 -345
  109. package/build/phase-result-artifacts.js +75 -20
  110. package/build/pipeline-orchestrator.js +247 -4
  111. package/build/pipelines.generated.js +35 -35
  112. package/build/plan-discovery.js +15 -2
  113. package/build/plan-execution-ledger.js +306 -17
  114. package/build/plan-phase-routing.js +48 -13
  115. package/build/plane/cli.js +105 -51
  116. package/build/plane/defaults.js +3 -0
  117. package/build/plane/plane-freshness.js +294 -0
  118. package/build/plane/schema-compatibility.js +137 -0
  119. package/build/plane/shutdown-deps.js +38 -0
  120. package/build/plane/shutdown.js +83 -8
  121. package/build/plane/terminal-run-cleanup.js +303 -0
  122. package/build/printed-remedy-commands.js +26 -4
  123. package/build/readiness-check.js +6 -2
  124. package/build/readiness-prerequisites.generated.js +532 -0
  125. package/build/readme.generated.js +1 -1
  126. package/build/setup-epic.js +400 -30
  127. package/build/shared-resources.generated.js +5 -0
  128. package/build/start-tickets.js +28 -15
  129. package/build/test-command-detection.js +247 -0
  130. package/build/test-command-validation.js +73 -0
  131. package/build/testing-config-client.js +150 -0
  132. package/build/ticket-delivery-artifacts.js +258 -0
  133. package/build/ticket-delivery-enrollment.js +306 -0
  134. package/build/ticket-delivery.js +205 -0
  135. package/build/ticket-review-summary.js +14 -0
  136. package/build/upgrade-cli.js +13 -0
  137. package/build/v2-readiness-requirements.js +552 -0
  138. package/build/version.generated.js +3 -3
  139. package/build/worker-guard-hook-bin.js +2 -2
  140. package/build/worktree-core.js +22 -7
  141. package/docs/BRIDGE-DIRECTORY-README.md +48 -0
  142. package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
  143. package/docs/EPIC-IMPLEMENTER.md +104 -63
  144. package/docs/epic-implementer/getting-started.md +131 -0
  145. package/docs/epic-implementer/self-hosting.md +133 -0
  146. package/package.json +3 -3
  147. package/pipelines/idea-to-ticket.json +1 -1
  148. package/pipelines/implement-ticket.json +8 -8
  149. package/pipelines/review-ticket.json +2 -2
  150. package/design-assets/favicon/favicon-32x32.png +0 -0
  151. package/design-assets/just-logo-rough-draft.png +0 -0
@@ -18,27 +18,22 @@ import { PLANE_RUNTIME_ENTRYPOINT_REFUSAL } from "./build-freshness.js";
18
18
  import { claimPlaneManifest, clearPlaneManifest, isCanonicalAbsolutePath, planeManifestSnapshot, readPlaneManifest, relativeLogPathFor, requestPlaneManifestLanes, } from "./manifest.js";
19
19
  import { lockCodeSnapshot, prepareCodeSnapshot, releaseCodeSnapshotForManifest, revalidateCodeSnapshot, } from "./code-snapshot.js";
20
20
  import { checkAlembicHead } from "./alembic-head.js";
21
+ import { checkSchemaCompatibility } from "./schema-compatibility.js";
21
22
  import { formatPlaneRestartOutcome, isPlaneRestartableMember, requestPlaneRestart, } from "./restart.js";
22
23
  import { runPlanePreflight } from "./preflight.js";
23
- import { buildPlaneControlPlaneRoster, buildPlaneExecutorLaneRoster, buildPlaneMemberRoster, buildPlaneObserverRoster, resolvePlaneServerEndpoint, } from "./member-roster.js";
24
+ import { buildPlaneControlPlaneRoster, buildPlaneExecutorLaneRoster, buildPlaneMemberRoster, buildPlaneObserverRoster, resolvePlaneServerEndpoint, resolvePythonExecutable, } from "./member-roster.js";
24
25
  import { getPlaneStatus, formatPlaneStatus } from "./status.js";
25
26
  import { shutdownPlane, formatPlaneShutdown } from "./shutdown.js";
27
+ import { buildPlaneTeardownDeps } from "./shutdown-deps.js";
28
+ import { cleanupTerminalRunPlane, createTerminalRunCleanupDeps, terminalRunCleanupStderrLine, } from "./terminal-run-cleanup.js";
26
29
  import { launchPlaneSupervisor, runPlaneRuntime, PLANE_RUNTIME_TWO_PHASE_FLAG, } from "./supervisor.js";
27
30
  import { createCodeSnapshotDeps, createMemberLogDeps, createPlaneClock, createPlaneExecFile, createPlaneFsDeps, createPlaneProcessDeps, createPlaneSpawn, planeFileExistsSync, probeAutomationHealth, probeTcpPort, registerSignalHandler, resolveHomedir, resolveHostname, resolveNodeExecutable, resolvePackageEntrypoint, } from "./defaults.js";
28
31
  import { resolveBapiCredentials } from "../credential-store.js";
29
- import { listRunExecutorRecords, verifyRunExecutorProcess } from "../executor-launch.js";
30
32
  import { resolveRequiredStartTicketsRepoName } from "../start-tickets-repo.js";
31
33
  /** Upper bound on executor lanes. Guards a typo from forking a fleet. */
32
34
  export const PLANE_MAX_EXECUTORS = 32;
33
35
  /** Default executor lane count when `--executors` is omitted. */
34
36
  export const PLANE_DEFAULT_EXECUTORS = 1;
35
- /**
36
- * Parse everything after the `plane` token.
37
- *
38
- * Pure by construction: every rejection happens here, before the coordinator
39
- * can touch the filesystem, open a socket, resolve a credential, or spawn a
40
- * child. A malformed invocation therefore has no side effect at all.
41
- */
42
37
  export function parsePlaneArgs(argv) {
43
38
  if (argv.length === 0) {
44
39
  return {
@@ -51,6 +46,30 @@ export function parsePlaneArgs(argv) {
51
46
  }
52
47
  const action = argv[0];
53
48
  const rest = argv.slice(1);
49
+ // BAPI-1496 — `plane down --expect-epic-run-id <id>`: wind down only the plane
50
+ // bound to that run. The one option `down` takes; everything else is refused.
51
+ if (action === "down" && rest.length > 0) {
52
+ if (rest[0] !== "--expect-epic-run-id") {
53
+ return {
54
+ kind: "error",
55
+ message: `unknown option '${rest[0]}' for \`plane down\` (expected --expect-epic-run-id <id>)`,
56
+ };
57
+ }
58
+ const raw = rest[1];
59
+ if (raw === undefined) {
60
+ return { kind: "error", message: "--expect-epic-run-id requires a value" };
61
+ }
62
+ if (raw.trim().length === 0) {
63
+ return { kind: "error", message: "--expect-epic-run-id requires a non-blank value" };
64
+ }
65
+ if (rest.length > 2) {
66
+ return {
67
+ kind: "error",
68
+ message: `unexpected argument '${rest[2]}' for \`plane down\` (--expect-epic-run-id may be supplied only once)`,
69
+ };
70
+ }
71
+ return { kind: "down", expectEpicRunId: raw.trim() };
72
+ }
54
73
  if (action === "status" || action === "down") {
55
74
  if (rest.length > 0) {
56
75
  return {
@@ -279,6 +298,11 @@ export function getPlaneUsage() {
279
298
  " `plane down && plane up` moves the plane to NEW code,",
280
299
  " so use it between runs (it stops a bound run); to",
281
300
  " recover one member, use `plane restart <member>`.",
301
+ " plane down --expect-epic-run-id <id>",
302
+ " Wind down ONLY if the plane is bound to run <id>.",
303
+ " The binding is checked before any run stop or signal;",
304
+ " an unbound plane, another run's plane, or an unreadable",
305
+ " manifest is refused with nothing stopped or signalled.",
282
306
  "",
283
307
  "Options:",
284
308
  ` --executors N Executor lanes to start (default ${PLANE_DEFAULT_EXECUTORS}, max ${PLANE_MAX_EXECUTORS}).`,
@@ -375,7 +399,7 @@ export async function runPlaneCli(argv, overrides = {}) {
375
399
  case "status":
376
400
  return await runStatusAction(sinks, overrides);
377
401
  case "down":
378
- return await runDownAction(sinks, overrides);
402
+ return await runDownAction(sinks, overrides, parsed.expectEpicRunId);
379
403
  case "restart":
380
404
  return await runRestartAction(parsed.member, sinks, overrides);
381
405
  case "up":
@@ -457,58 +481,61 @@ async function runRestartAction(member, sinks, overrides) {
457
481
  (rendered.ok ? sinks.stdout : sinks.stderr)(line);
458
482
  return rendered.ok ? 0 : 1;
459
483
  }
460
- async function runDownAction(sinks, overrides) {
484
+ /** Said when `plane down --expect-epic-run-id` refuses (BAPI-1496). Fixed prose. */
485
+ export const PLANE_DOWN_OWNERSHIP_REFUSED = "plane down REFUSED: the plane is not bound to the expected epic run (or its manifest could not " +
486
+ "be read). Nothing was stopped or signalled and the manifest was kept; inspect it with " +
487
+ "`plane status`.";
488
+ async function runDownAction(sinks, overrides, expectEpicRunId) {
461
489
  const repoRoot = overrides.cwd ?? process.cwd();
462
490
  if (overrides.down) {
463
- const injected = await overrides.down(repoRoot);
491
+ const injected = expectEpicRunId === undefined
492
+ ? await overrides.down(repoRoot)
493
+ : await overrides.down(repoRoot, { expectEpicRunId });
464
494
  (injected.ok ? sinks.stdout : sinks.stderr)(injected.text);
465
495
  return injected.ok ? 0 : 1;
466
496
  }
467
497
  const fs = createPlaneFsDeps();
468
- const proc = createPlaneProcessDeps();
469
- const execFile = createPlaneExecFile();
470
- const endpoint = resolvePlaneServerEndpoint(process.env);
471
- const snapshotDeps = createCodeSnapshotDeps(proc);
472
- const result = await shutdownPlane(repoRoot, {
473
- fs,
474
- proc,
475
- clock: createPlaneClock(),
476
- // BAPI-1362 — unlock the pinned snapshot, but only once this wind-down has
477
- // confirmed every process gone and cleared the manifest. It stays on disk.
478
- releaseSnapshot: (manifest) => releaseCodeSnapshotForManifest(manifest, snapshotDeps),
498
+ const result = await shutdownPlane(repoRoot, buildPlaneTeardownDeps({
499
+ repoRoot,
500
+ env: process.env,
479
501
  stopBoundRun: stopBoundEpicRun,
480
- // BAPI-1279 — stop the run-scoped executors drive-epic started here that
481
- // target THIS plane's endpoint (the same rule `plane up` binds by).
482
- runExecutors: {
483
- list: () => listRunExecutorRecords(repoRoot, fs),
484
- targetsThisPlane: (baseUrl) => endpoint.ok && urlNamesLoopbackPort(baseUrl, endpoint.endpoint.port),
485
- verify: (record) => verifyRunExecutorProcess(record, { isAlive: proc.isAlive, execFile }, repoRoot),
486
- },
487
- });
502
+ ...(expectEpicRunId !== undefined
503
+ ? { ownershipGuard: expectedRunOwnershipGuard(repoRoot, expectEpicRunId, fs) }
504
+ : {}),
505
+ }));
506
+ // An unreadable manifest cannot show the expected binding either, so under
507
+ // `--expect-epic-run-id` it is refused with the same fixed line (BAPI-1496).
508
+ if (!result.ok &&
509
+ (result.reason === "ownership-aborted" ||
510
+ (expectEpicRunId !== undefined && result.reason === "unvalidated-manifest"))) {
511
+ sinks.stderr(PLANE_DOWN_OWNERSHIP_REFUSED);
512
+ return 1;
513
+ }
488
514
  const text = formatPlaneShutdown(result);
489
515
  (result.ok ? sinks.stdout : sinks.stderr)(text);
490
516
  return result.ok ? 0 : 1;
491
517
  }
492
- /** Loopback spellings that reach the plane's `127.0.0.1` bind. */
493
- const PLANE_LOOPBACK_HOSTNAMES = new Set([
494
- "127.0.0.1",
495
- "localhost",
496
- "[::1]",
497
- "::1",
498
- ]);
499
- /** Does `baseUrl` name a loopback `http` endpoint on `port`? Pure. */
500
- function urlNamesLoopbackPort(baseUrl, port) {
501
- let url;
502
- try {
503
- url = new URL(baseUrl);
504
- }
505
- catch {
506
- return false;
507
- }
508
- if (url.protocol !== "http:")
509
- return false;
510
- const actual = url.port === "" ? 80 : Number(url.port);
511
- return PLANE_LOOPBACK_HOSTNAMES.has(url.hostname) && actual === port;
518
+ /**
519
+ * The `--expect-epic-run-id` ownership guard (BAPI-1496).
520
+ *
521
+ * The manifest `shutdownPlane` validated must be bound to the expected run, and
522
+ * a fresh re-read must still show the same plane bound to it — so a plane that
523
+ * was replaced between the two reads is never signalled. Fixed abort wording:
524
+ * the CLI prints {@link PLANE_DOWN_OWNERSHIP_REFUSED} instead.
525
+ */
526
+ function expectedRunOwnershipGuard(repoRoot, expectEpicRunId, fs) {
527
+ return async (manifest) => {
528
+ if (manifest.epicRunId !== expectEpicRunId) {
529
+ return { kind: "abort", message: "the plane is not bound to the expected epic run" };
530
+ }
531
+ const reread = await readPlaneManifest(repoRoot, fs);
532
+ if (reread.kind !== "valid" ||
533
+ reread.manifest.planeId !== manifest.planeId ||
534
+ reread.manifest.epicRunId !== expectEpicRunId) {
535
+ return { kind: "abort", message: "the plane manifest changed during wind-down" };
536
+ }
537
+ return { kind: "proceed" };
538
+ };
512
539
  }
513
540
  /**
514
541
  * `plane down`'s pre-signal run-stop capability (BAPI-872).
@@ -555,6 +582,22 @@ async function runUpAction(options, sinks, overrides) {
555
582
  const executors = options.executors;
556
583
  const repoRoot = overrides.cwd ?? process.cwd();
557
584
  const env = overrides.env ?? process.env;
585
+ // BAPI-1496 — before preflight, tear down this checkout's plane if it is bound
586
+ // to a run that has FINISHED and nothing else depends on it; otherwise the
587
+ // `existing-plane` check below would refuse over a plane nobody needs. Only for
588
+ // the resolved local-plane endpoint: an unresolvable one is preflight's to
589
+ // report. An ineligible plane is left alone silently; an incomplete or aborted
590
+ // cleanup says so once and keeps its manifest, and preflight then refuses over
591
+ // it exactly as it always has — there is no exemption.
592
+ const planeEndpoint = resolvePlaneServerEndpoint(env);
593
+ if (planeEndpoint.ok) {
594
+ const cleanup = overrides.terminalRunCleanup
595
+ ? await overrides.terminalRunCleanup({ repoRoot, endpoint: planeEndpoint.endpoint })
596
+ : await cleanupTerminalRunPlane({ repoRoot, endpoint: planeEndpoint.endpoint }, createTerminalRunCleanupDeps(env));
597
+ const line = terminalRunCleanupStderrLine(cleanup);
598
+ if (line !== null)
599
+ sinks.stderr(line);
600
+ }
558
601
  const preflight = overrides.preflight
559
602
  ? await overrides.preflight(repoRoot)
560
603
  : await runDefaultPlanePreflight(repoRoot, env);
@@ -699,6 +742,17 @@ async function launchPlane(preflight, executors, planeId, sinks, env, launchOpti
699
742
  }
700
743
  if (alembic.diagnostic)
701
744
  sinks.stdout(formatDiagnostic(alembic.diagnostic));
745
+ // BAPI-1455 — a stamp is not proof of schema. Ask the shared Python check
746
+ // whether the launch-critical objects exist, from the snapshot with the
747
+ // interpreter the Python members will use. Still before any claim or spawn:
748
+ // only a proven-missing object refuses; anything unverifiable warns.
749
+ sinks.stdout("Verifying local launch-critical schema…");
750
+ const schema = await checkSchemaCompatibility(resolvePythonExecutable(env, operatorVenvLookup(context.repoRoot)), prepared.snapshot.path, { execFile: createPlaneExecFile() });
751
+ if (schema.diagnostic?.severity === "blocking") {
752
+ return { ok: false, error: schema.diagnostic.message, startup: null };
753
+ }
754
+ if (schema.diagnostic)
755
+ sinks.stdout(formatDiagnostic(schema.diagnostic));
702
756
  context = { ...context, snapshot: prepared.snapshot };
703
757
  }
704
758
  const snapshot = context.snapshot;
@@ -131,6 +131,9 @@ export function createPlaneExecFile() {
131
131
  shell: false,
132
132
  windowsHide: true,
133
133
  timeout: options.timeoutMs ?? 30_000,
134
+ // Merged over the inherited environment, so a caller adds a variable
135
+ // without losing PATH or the user's Git configuration (BAPI-1476).
136
+ ...(options.env !== undefined ? { env: { ...process.env, ...options.env } } : {}),
134
137
  }, (error, stdout, stderr) => {
135
138
  if (error) {
136
139
  const code = error.code;
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Local-plane freshness evidence (BAPI-1476).
3
+ *
4
+ * Three read-only observations a launch collects BEFORE it creates a run:
5
+ *
6
+ * - `plane-snapshot-freshness` — is the plane's pinned code snapshot behind
7
+ * `origin/main`?
8
+ * - `plane-database-head` — is the database at the migration head of the code
9
+ * the plane will run? (Delegates to {@link checkAlembicHead}, unchanged.)
10
+ * - `host-sleep` — is this Mac likely to sleep under a long run? (Advisory only.)
11
+ *
12
+ * This module only OBSERVES. It does not decide whether a launch may proceed,
13
+ * does not format output, and never prints. Whether a finding blocks run creation
14
+ * is the orchestrator's call (`drive-epic.ts`), because the same finding blocks a
15
+ * create and only warns on a reattach to a run that already exists.
16
+ *
17
+ * Asymmetry, as with the Alembic check: a finding is `fail` (blocking severity)
18
+ * ONLY when the evidence is confirmed. A command failure, a timeout, a missing
19
+ * object, shallow history, or output it cannot parse is an `unverified` warning —
20
+ * a false refusal is worse than a skipped check. Unverified detail is fixed prose:
21
+ * it never carries raw stderr, an error string, a credential, or parser internals.
22
+ *
23
+ * Nothing here mutates the checkout, the plane, the database, power settings, or
24
+ * any run. `git fetch` updates remote-tracking refs, which is the one write.
25
+ */
26
+ import { checkAlembicHead, } from "./alembic-head.js";
27
+ import { planeCheckFinding, planeCheckPassed, planeCheckSkipped, } from "./types.js";
28
+ /** Finite bound on every subprocess this module starts. */
29
+ export const PLANE_FRESHNESS_TIMEOUT_MS = 30_000;
30
+ export const PLANE_SNAPSHOT_FRESHNESS_ID = "plane-snapshot-freshness";
31
+ export const PLANE_DATABASE_HEAD_ID = "plane-database-head";
32
+ export const PLANE_HOST_SLEEP_ID = "host-sleep";
33
+ const SNAPSHOT_LABEL = "Plane snapshot freshness";
34
+ const SLEEP_LABEL = "Host sleep";
35
+ /** Said on every unverified report. The operator's one next step. */
36
+ export const PLANE_FRESHNESS_UNVERIFIED_REMEDY = "restore the missing evidence and rerun";
37
+ /**
38
+ * The ordered fix for a snapshot behind `origin/main`. The first step is the
39
+ * operator's: this CLI never updates the checkout.
40
+ */
41
+ export const PLANE_SNAPSHOT_STALE_REMEDY = "bring the main checkout up to `origin/main`, then run `plane down` and `plane up`. " +
42
+ "This CLI does not update the checkout. `--allow-stale-plane` overrides this refusal " +
43
+ "for one invocation only and repairs nothing.";
44
+ /**
45
+ * BAPI-1496 — the stale-snapshot fix for a plane BOUND to an epic run.
46
+ *
47
+ * {@link PLANE_SNAPSHOT_STALE_REMEDY} says "run `plane down`", which is right for
48
+ * an unbound plane and wrong for a bound one: `plane down` stops the bound run
49
+ * server-side. So a bound plane gets ownership-specific advice instead, chosen by
50
+ * the run's validated status. These fragments are fixed prose; only a validated
51
+ * run id and a status from the closed vocabulary below are ever interpolated.
52
+ */
53
+ export const PLANE_BOUND_STALE_OVERRIDE_NOTE = "`--allow-stale-plane` overrides this refusal for one invocation only and repairs nothing.";
54
+ /** Run statuses the bound remedy may name. Anything else renders as unknown. */
55
+ const BOUND_RUN_STATUSES = new Set([
56
+ "planning",
57
+ "pending_approval",
58
+ "active",
59
+ "blocked",
60
+ "stopped",
61
+ "abandoned",
62
+ "done",
63
+ ]);
64
+ /** The run statuses after which nothing more will happen (as `wait-run` uses). */
65
+ const BOUND_RUN_TERMINAL_STATUSES = new Set(["done", "abandoned"]);
66
+ /** A run id is rendered only when it has a plain, bounded identifier shape. */
67
+ const BOUND_RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
68
+ /**
69
+ * The stale-snapshot remedy for a plane bound to an epic run (BAPI-1496).
70
+ *
71
+ * - Non-terminal and unknown status: inspect the run and the plane. It NEVER
72
+ * recommends `plane down`, because that would stop a run that may still be
73
+ * working, and an unknown status is not evidence that it is finished.
74
+ * - Terminal status (`done`/`abandoned`): the next local launch's guarded cleanup
75
+ * tears the plane down; if that cleanup reports it was incomplete, inspect with
76
+ * `plane status` before any manual wind-down.
77
+ *
78
+ * Pure and secret-free: the output names only the run id and a closed status.
79
+ */
80
+ export function formatBoundPlaneStaleRemedy(input) {
81
+ const runLabel = BOUND_RUN_ID_PATTERN.test(input.epicRunId)
82
+ ? `run ${input.epicRunId}`
83
+ : "its bound run";
84
+ const status = typeof input.status === "string" && BOUND_RUN_STATUSES.has(input.status) ? input.status : null;
85
+ if (status !== null && BOUND_RUN_TERMINAL_STATUSES.has(status)) {
86
+ return (`this plane is bound to ${runLabel}, which is ${status}. Bring the main checkout up to ` +
87
+ "`origin/main`; the next local launch's guarded cleanup then winds this plane down " +
88
+ "and starts a fresh one. If that cleanup reports it was incomplete, inspect the plane " +
89
+ "with `plane status` before winding it down by hand. This CLI does not update the " +
90
+ `checkout. ${PLANE_BOUND_STALE_OVERRIDE_NOTE}`);
91
+ }
92
+ const statusText = status === null ? "whose status could not be confirmed" : `which is ${status}`;
93
+ return (`this plane is bound to ${runLabel}, ${statusText}. Do not wind it down while that run ` +
94
+ "may still be working: inspect the run and the plane (`plane status`) first, and let the " +
95
+ "run finish or stop it deliberately before refreshing the plane. This CLI does not update " +
96
+ `the checkout. ${PLANE_BOUND_STALE_OVERRIDE_NOTE}`);
97
+ }
98
+ /** The fix for a confirmed database-head mismatch, plus the override. */
99
+ export const PLANE_DATABASE_HEAD_REMEDY = "run `alembic -c alembic.ini upgrade head`, then retry. `--allow-stale-plane` overrides " +
100
+ "this refusal for one invocation only and repairs nothing.";
101
+ export const PLANE_SLEEP_BATTERY_ADVISORY = "the host is drawing battery power, so it may sleep during a long run";
102
+ export const PLANE_SLEEP_LID_ADVISORY = "closing the lid will sleep the host (SleepDisabled is not 1)";
103
+ export const PLANE_SLEEP_REMEDY = "executor `caffeinate -i -s` only holds the host awake on AC power: plug in and keep the lid open.";
104
+ const HEX_OBJECT_ID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
105
+ const GIT_ENV = { GIT_TERMINAL_PROMPT: "0" };
106
+ function unverifiedSnapshot(reason) {
107
+ return planeCheckFinding({
108
+ check: PLANE_SNAPSHOT_FRESHNESS_ID,
109
+ severity: "warning",
110
+ message: `could not verify plane snapshot freshness: ${reason}.`,
111
+ remediation: PLANE_FRESHNESS_UNVERIFIED_REMEDY,
112
+ }, SNAPSHOT_LABEL, `could not verify plane snapshot freshness: ${reason}`);
113
+ }
114
+ async function git(deps, repoRoot, args, timeoutMs) {
115
+ const result = await deps.execFile("git", args, { cwd: repoRoot, timeoutMs, env: { ...GIT_ENV } });
116
+ return result.ok ? result.stdout.trim() : null;
117
+ }
118
+ /** Resolve a revision to a commit id, or `null` for anything that is not one. */
119
+ async function resolveCommit(deps, repoRoot, revision, timeoutMs) {
120
+ const out = await git(deps, repoRoot, ["rev-parse", "--verify", "--quiet", `${revision}^{commit}`], timeoutMs);
121
+ return out !== null && HEX_OBJECT_ID.test(out) ? out : null;
122
+ }
123
+ async function checkSnapshot(repoRoot, deps, options, timeoutMs, manifestRead) {
124
+ if (!options.ownsManifest) {
125
+ return unverifiedSnapshot("no plane manifest in this checkout belongs to the selected plane");
126
+ }
127
+ if (manifestRead === null || manifestRead.kind !== "valid") {
128
+ return unverifiedSnapshot("the plane manifest could not be read");
129
+ }
130
+ const manifest = manifestRead.manifest;
131
+ if (manifest.repoRoot !== repoRoot) {
132
+ return unverifiedSnapshot("the plane manifest belongs to a different checkout");
133
+ }
134
+ if (options.expectedPlaneId !== undefined && manifest.planeId !== options.expectedPlaneId) {
135
+ return unverifiedSnapshot("the plane manifest is not the selected plane's");
136
+ }
137
+ const snapshotSha = manifest.snapshotSha;
138
+ if (snapshotSha === undefined || !HEX_OBJECT_ID.test(snapshotSha)) {
139
+ return unverifiedSnapshot("the plane manifest records no pinned snapshot");
140
+ }
141
+ // Refresh the remote-tracking ref first. A failed fetch stops this check: a
142
+ // stale cached `origin/main` would read as "fresh" and prove nothing.
143
+ if ((await git(deps, repoRoot, ["fetch", "--quiet", "--no-tags", "origin", "main"], timeoutMs)) === null) {
144
+ return unverifiedSnapshot("`origin` could not be fetched");
145
+ }
146
+ const snapshotCommit = await resolveCommit(deps, repoRoot, snapshotSha, timeoutMs);
147
+ if (snapshotCommit === null) {
148
+ return unverifiedSnapshot("the snapshot commit is not present in this checkout");
149
+ }
150
+ const originCommit = await resolveCommit(deps, repoRoot, "origin/main", timeoutMs);
151
+ if (originCommit === null) {
152
+ return unverifiedSnapshot("`origin/main` could not be resolved");
153
+ }
154
+ if (snapshotCommit === originCommit) {
155
+ return planeCheckPassed(PLANE_SNAPSHOT_FRESHNESS_ID, SNAPSHOT_LABEL, "the snapshot is at origin/main");
156
+ }
157
+ // The injected runner reports success or failure, not an exit status, so
158
+ // ancestry is read off VALIDATED `merge-base` output rather than from a
159
+ // nonzero exit that could equally mean "not an ancestor" or "git failed".
160
+ const base = await git(deps, repoRoot, ["merge-base", snapshotCommit, originCommit], timeoutMs);
161
+ if (base === null || !HEX_OBJECT_ID.test(base)) {
162
+ return unverifiedSnapshot("the ancestry of the snapshot could not be determined");
163
+ }
164
+ if (base === originCommit) {
165
+ return planeCheckPassed(PLANE_SNAPSHOT_FRESHNESS_ID, SNAPSHOT_LABEL, "the snapshot is ahead of origin/main");
166
+ }
167
+ if (base === snapshotCommit) {
168
+ return planeCheckFinding({
169
+ check: PLANE_SNAPSHOT_FRESHNESS_ID,
170
+ severity: "blocking",
171
+ message: "the plane snapshot is behind origin/main.",
172
+ remediation: PLANE_SNAPSHOT_STALE_REMEDY,
173
+ }, SNAPSHOT_LABEL, "the plane snapshot is behind origin/main");
174
+ }
175
+ // Both sides moved. That is not proof of staleness, and not proof of freshness.
176
+ return unverifiedSnapshot("the snapshot and origin/main have diverged");
177
+ }
178
+ /**
179
+ * Collect the snapshot and database freshness reports, independently: an
180
+ * unverified snapshot never suppresses a confirmed database refusal, or the
181
+ * reverse.
182
+ *
183
+ * `checkout` is the code root whose `alembic.ini` the database check reads. When
184
+ * the selected plane's manifest pins a snapshot, that snapshot is used instead —
185
+ * the code the plane will actually RUN — exactly as plane preflight does.
186
+ */
187
+ export async function checkPlaneFreshness(repoRoot, deps, checkout, options) {
188
+ const timeoutMs = options.timeoutMs ?? PLANE_FRESHNESS_TIMEOUT_MS;
189
+ let manifestRead = null;
190
+ if (options.ownsManifest) {
191
+ try {
192
+ manifestRead = await deps.readPlaneManifest(repoRoot);
193
+ }
194
+ catch {
195
+ manifestRead = null;
196
+ }
197
+ }
198
+ const codeRoot = manifestRead?.kind === "valid" && manifestRead.manifest.snapshotPath !== undefined
199
+ ? manifestRead.manifest.snapshotPath
200
+ : checkout;
201
+ const guarded = async (run, fallback) => {
202
+ try {
203
+ return await run();
204
+ }
205
+ catch {
206
+ return fallback();
207
+ }
208
+ };
209
+ const [snapshot, database] = await Promise.all([
210
+ guarded(() => checkSnapshot(repoRoot, deps, options, timeoutMs, manifestRead), () => unverifiedSnapshot("the check did not complete")),
211
+ guarded(() => checkAlembicHead(repoRoot, { execFile: deps.execFile, fileExists: deps.fileExists, platform: deps.platform }, codeRoot), () => planeCheckFinding({
212
+ check: "alembic-head",
213
+ severity: "warning",
214
+ message: "could not verify database migration head: the check did not complete.",
215
+ remediation: PLANE_FRESHNESS_UNVERIFIED_REMEDY,
216
+ }, "Database migration head", "could not verify database migration head: the check did not complete")),
217
+ ]);
218
+ return { snapshot, database };
219
+ }
220
+ function unverifiedSleep(detail, advisories = []) {
221
+ const text = [...advisories, detail].join("; ");
222
+ return planeCheckFinding({
223
+ check: PLANE_HOST_SLEEP_ID,
224
+ severity: "warning",
225
+ message: text,
226
+ remediation: PLANE_FRESHNESS_UNVERIFIED_REMEDY,
227
+ }, SLEEP_LABEL, text);
228
+ }
229
+ /**
230
+ * Read this Mac's sleep posture. Advisory only: it never blocks setup.
231
+ *
232
+ * "Pass" here means "does not block setup", not "guaranteed to stay awake".
233
+ * `not applicable` (a skip, never a pass) off macOS. Lid-close risk comes from
234
+ * `pmset -g`'s `SleepDisabled`, not the idle-sleep values in `pmset -g custom`:
235
+ * the executor holds idle sleep off itself, but nothing holds a closed lid open.
236
+ */
237
+ export async function inspectHostSleep(repoRoot, deps, options = {}) {
238
+ if (deps.platform !== "darwin") {
239
+ return planeCheckSkipped(PLANE_HOST_SLEEP_ID, SLEEP_LABEL, "not applicable on this platform");
240
+ }
241
+ const timeoutMs = options.timeoutMs ?? PLANE_FRESHNESS_TIMEOUT_MS;
242
+ const run = async (args) => {
243
+ try {
244
+ const result = await deps.execFile("pmset", args, { cwd: repoRoot, timeoutMs });
245
+ return result.ok ? result.stdout : null;
246
+ }
247
+ catch {
248
+ return null;
249
+ }
250
+ };
251
+ const [batt, settings] = await Promise.all([run(["-g", "batt"]), run(["-g"])]);
252
+ const advisories = [];
253
+ let battPresent = null;
254
+ let onBattery = null;
255
+ if (batt !== null) {
256
+ const source = /Now drawing from '([^']+)'/.exec(batt);
257
+ if (source !== null) {
258
+ onBattery = /battery/i.test(source[1] ?? "");
259
+ battPresent = onBattery || /InternalBattery|present:\s*true/i.test(batt);
260
+ }
261
+ }
262
+ if (onBattery === true)
263
+ advisories.push(PLANE_SLEEP_BATTERY_ADVISORY);
264
+ let sleepDisabled = null;
265
+ if (settings !== null) {
266
+ const match = /^\s*SleepDisabled\s+(\d)/m.exec(settings);
267
+ if (match !== null)
268
+ sleepDisabled = match[1] === "1";
269
+ }
270
+ if (battPresent === true && sleepDisabled === false)
271
+ advisories.push(PLANE_SLEEP_LID_ADVISORY);
272
+ // Anything we could not read is "unverified", never a quiet pass. Battery
273
+ // evidence that WAS readable is kept in the same report.
274
+ if (battPresent === null || (battPresent === true && sleepDisabled === null)) {
275
+ return unverifiedSleep("could not verify sleep settings", advisories);
276
+ }
277
+ if (advisories.length === 0) {
278
+ return planeCheckPassed(PLANE_HOST_SLEEP_ID, SLEEP_LABEL, "no host sleep risk found; does not block setup");
279
+ }
280
+ const detail = `${advisories.join("; ")}. ${PLANE_SLEEP_REMEDY} Does not block setup.`;
281
+ return {
282
+ diagnostic: null,
283
+ outcomes: [
284
+ {
285
+ check: PLANE_HOST_SLEEP_ID,
286
+ id: PLANE_HOST_SLEEP_ID,
287
+ label: SLEEP_LABEL,
288
+ status: "pass",
289
+ // The advice lives in `detail`: a passing outcome carries no remediation.
290
+ detail,
291
+ },
292
+ ],
293
+ };
294
+ }