@bridge_gpt/mcp-server 0.2.66 → 0.2.67

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 (100) hide show
  1. package/README.md +252 -77
  2. package/build/agent-capabilities/probes.js +15 -5
  3. package/build/agent-launchers/claude-executor-adapter.js +43 -16
  4. package/build/agent-launchers/executor-adapter.js +7 -4
  5. package/build/agents.generated.js +1 -1
  6. package/build/bounded-wait.js +67 -10
  7. package/build/commands.generated.js +11 -11
  8. package/build/conductor/bridge-api-client.js +143 -1
  9. package/build/conductor/bring-up-facts.js +1 -1
  10. package/build/conductor/doctor.js +3 -3
  11. package/build/conductor/epic-implementer-cli.js +22 -6
  12. package/build/conductor/errors.js +4 -3
  13. package/build/conductor/guided-repair.js +6 -2
  14. package/build/conductor/hook-runtime-qualification.js +1 -1
  15. package/build/conductor/merge-execution-contract.js +27 -8
  16. package/build/conductor/merge-execution-eligibility.js +3 -2
  17. package/build/conductor/pr-ci-producer.js +1 -1
  18. package/build/conductor/readiness-cli.js +18 -2
  19. package/build/conductor/readiness-sections.js +41 -6
  20. package/build/conductor/readiness.js +181 -29
  21. package/build/conductor/recovery-cli.js +200 -8
  22. package/build/conductor/recovery-operations.js +69 -3
  23. package/build/conductor/repair-contract.js +54 -3
  24. package/build/conductor/repository-workflow-remedy.js +125 -0
  25. package/build/conductor/tools.js +2 -2
  26. package/build/conductor-bundle-artifacts.js +8 -0
  27. package/build/docs.generated.js +8 -3
  28. package/build/drive-epic-key.js +6 -5
  29. package/build/drive-epic.js +85 -54
  30. package/build/epic-coherence-input.js +2 -1
  31. package/build/epic-implementer-bin.js +22 -20
  32. package/build/epic-implementer-bundle-cli.js +2 -2
  33. package/build/executor/cli.js +3 -2
  34. package/build/executor/completion-contract.js +48 -5
  35. package/build/executor/completion-supervision.js +4 -0
  36. package/build/executor/env.js +19 -0
  37. package/build/executor/heartbeat.js +1 -1
  38. package/build/executor/http-client.js +20 -0
  39. package/build/executor/job-errors.js +25 -0
  40. package/build/executor/job-runner.js +225 -41
  41. package/build/executor/observation.js +6 -1
  42. package/build/executor/preflight.js +51 -14
  43. package/build/executor/required-work-ledger.js +6 -1
  44. package/build/executor/resume-pre-spawn.js +2 -0
  45. package/build/executor/runner.js +8 -2
  46. package/build/executor/service-unit.js +7 -1
  47. package/build/executor/worker-finalization.js +82 -29
  48. package/build/executor/worker-guard-hook.js +137 -33
  49. package/build/executor/worktree.js +82 -1
  50. package/build/index.js +403 -429
  51. package/build/install-bridge-conductor.js +8 -6
  52. package/build/install-bridge.js +3 -3
  53. package/build/phase-context-git-probe.js +68 -0
  54. package/build/phase-result-artifacts.js +173 -5
  55. package/build/pipelines.generated.js +18 -24
  56. package/build/plane/alembic-head.js +12 -3
  57. package/build/plane/base-worktree-guard.js +147 -0
  58. package/build/plane/cli.js +217 -17
  59. package/build/plane/code-snapshot.js +468 -0
  60. package/build/plane/defaults.js +48 -3
  61. package/build/plane/endpoint.js +41 -0
  62. package/build/plane/manifest.js +120 -1
  63. package/build/plane/member-roster.js +94 -19
  64. package/build/plane/preflight.js +30 -2
  65. package/build/plane/process-cwd.js +67 -0
  66. package/build/plane/restart.js +542 -0
  67. package/build/plane/shutdown.js +31 -0
  68. package/build/plane/status.js +143 -8
  69. package/build/plane/supervisor.js +586 -179
  70. package/build/plane/types.js +54 -2
  71. package/build/printed-remedy-commands.js +37 -0
  72. package/build/readiness-check.js +1 -1
  73. package/build/readme.generated.js +1 -1
  74. package/build/review-generation.js +22 -0
  75. package/build/setup-epic.js +44 -19
  76. package/build/sfcc/config.js +9 -0
  77. package/build/sfcc/credentials.js +100 -38
  78. package/build/sfcc/hostname.js +67 -0
  79. package/build/sfcc/permissions.js +17 -13
  80. package/build/sfcc/register.js +1 -1
  81. package/build/sfcc/tool-wrapper.js +39 -43
  82. package/build/sfcc/write-grants.js +17 -11
  83. package/build/sfcc/write-result.js +4 -3
  84. package/build/sfcc/writes-site-preference.js +20 -8
  85. package/build/start-tickets.js +179 -31
  86. package/build/tool-error-envelope.js +61 -0
  87. package/build/version.generated.js +3 -3
  88. package/build/visual-diff.js +22 -1
  89. package/build/worker-guard-hook-bin.js +3 -6
  90. package/build/worker-guard-hook-main.js +47 -0
  91. package/build/worktree-core.js +130 -4
  92. package/docs/EPIC-IMPLEMENTER-INSTALL.md +849 -0
  93. package/docs/EPIC-IMPLEMENTER.md +662 -0
  94. package/docs/install/mcp-tool-integrations.md +24 -15
  95. package/docs/install/sfcc-integration.md +12 -4
  96. package/package.json +1 -1
  97. package/pipelines/plan-epic.json +0 -5
  98. package/pipelines/review-ticket.json +1 -1
  99. package/docs/CONDUCTOR.md +0 -605
  100. package/pipelines/idea-to-pr.json +0 -49
@@ -17,7 +17,7 @@
17
17
  * argv, and no credential field, and unknown keys are rejected outright.
18
18
  */
19
19
  import path from "path";
20
- import { PLANE_LIFECYCLE_PHASES, PLANE_MANIFEST_FILENAME, PLANE_MANIFEST_SUPPORTED_SCHEMA_VERSIONS, PLANE_RUNTIME_DIR, } from "./types.js";
20
+ import { PLANE_LIFECYCLE_PHASES, PLANE_MANIFEST_FILENAME, PLANE_MANIFEST_SUPPORTED_SCHEMA_VERSIONS, PLANE_RESTART_FAILURE_REASONS, PLANE_RESTART_REQUEST_FILENAME, PLANE_RESTART_RESULT_FILENAME, PLANE_RUNTIME_DIR, } from "./types.js";
21
21
  /**
22
22
  * Derive every runtime path beneath a validated repository root.
23
23
  *
@@ -27,12 +27,62 @@ import { PLANE_LIFECYCLE_PHASES, PLANE_MANIFEST_FILENAME, PLANE_MANIFEST_SUPPORT
27
27
  */
28
28
  export function getPlanePaths(repoRoot) {
29
29
  const planeDir = path.join(repoRoot, ".bridge", "plane");
30
+ const snapshotsDir = path.join(planeDir, "snapshots");
30
31
  return {
31
32
  planeDir,
32
33
  manifestPath: path.join(planeDir, PLANE_MANIFEST_FILENAME),
33
34
  logPathFor: (member) => path.join(planeDir, `${member}.log`),
35
+ snapshotsDir,
36
+ // The leaf is the ONLY variable part and it is shape-checked before it is
37
+ // joined: a value that is not a bare hexadecimal commit id throws rather than
38
+ // producing a path, so no caller-supplied string can steer a snapshot path
39
+ // out of `.bridge/plane/snapshots/`.
40
+ snapshotPathFor: (sha) => {
41
+ if (!isFullCommitSha(sha)) {
42
+ throw new Error("a snapshot path can only be derived from a full hexadecimal commit id");
43
+ }
44
+ return path.join(snapshotsDir, sha);
45
+ },
46
+ // Fixed file names. The restart request and result are correlated by the
47
+ // identifiers INSIDE them, never by a name derived from their content.
48
+ restartRequestPath: path.join(planeDir, PLANE_RESTART_REQUEST_FILENAME),
49
+ restartResultPath: path.join(planeDir, PLANE_RESTART_RESULT_FILENAME),
34
50
  };
35
51
  }
52
+ /**
53
+ * A full Git object id: 40 hexadecimal characters (SHA-1) or 64 (SHA-256).
54
+ *
55
+ * Lowercase only. `git rev-parse` prints lowercase, so an uppercase spelling is
56
+ * not something this code produced, and accepting it would let two spellings of
57
+ * one commit name two different snapshot directories.
58
+ */
59
+ export function isFullCommitSha(value) {
60
+ return typeof value === "string" && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value);
61
+ }
62
+ /**
63
+ * A canonical absolute path: absolute, already normalized (no `.` or `..`
64
+ * segment, no doubled or trailing separator), and free of control characters.
65
+ *
66
+ * "Canonical" here is lexical — it says the string IS its own normal form — and
67
+ * it does not claim the path exists or is free of symlinks. Symlink resolution
68
+ * needs a filesystem and belongs to the snapshot module's ownership checks.
69
+ */
70
+ export function isCanonicalAbsolutePath(value) {
71
+ if (typeof value !== "string" || value.length === 0)
72
+ return false;
73
+ // eslint-disable-next-line no-control-regex
74
+ if (/[\u0000-\u001f]/.test(value))
75
+ return false;
76
+ if (!path.isAbsolute(value))
77
+ return false;
78
+ return path.normalize(value) === value && (value === path.parse(value).root || !value.endsWith(path.sep));
79
+ }
80
+ export function planeManifestSnapshot(manifest) {
81
+ if (manifest.snapshotPath !== undefined && manifest.snapshotSha !== undefined) {
82
+ return { kind: "pinned", snapshot: { path: manifest.snapshotPath, sha: manifest.snapshotSha } };
83
+ }
84
+ return { kind: "legacy" };
85
+ }
36
86
  /** Repository-relative log path recorded in the manifest for a member. */
37
87
  export function relativeLogPathFor(member) {
38
88
  return `${PLANE_RUNTIME_DIR}/${member}.log`;
@@ -42,7 +92,10 @@ const VALID_STATES = new Set([
42
92
  "running",
43
93
  "ready",
44
94
  "exited",
95
+ "restarting",
45
96
  ]);
97
+ /** Every legal `restartFailure` value, for the strict member parse. */
98
+ const RESTART_FAILURE_REASONS = new Set(PLANE_RESTART_FAILURE_REASONS);
46
99
  const MANIFEST_KEYS = new Set([
47
100
  "schemaVersion",
48
101
  "planeId",
@@ -58,6 +111,9 @@ const MANIFEST_KEYS = new Set([
58
111
  // starts its lanes with. Both optional; both absent on a v1/v2 manifest.
59
112
  "lifecycle",
60
113
  "laneScope",
114
+ // BAPI-1362: the pinned code snapshot. Optional, and only ever both or neither.
115
+ "snapshotPath",
116
+ "snapshotSha",
61
117
  ]);
62
118
  /** Every legal `lifecycle` value, as a set for the strict parse below. */
63
119
  const LIFECYCLE_PHASES = new Set(PLANE_LIFECYCLE_PHASES);
@@ -72,6 +128,9 @@ const MEMBER_KEYS = new Set([
72
128
  "exitCode",
73
129
  "exitSignal",
74
130
  "logPath",
131
+ // BAPI-1362: both optional, both absent on a pre-v4 manifest.
132
+ "cwd",
133
+ "restartFailure",
75
134
  ]);
76
135
  /**
77
136
  * `server`, `worker`, `observer`, or `executor-<positive integer>`. Nothing else.
@@ -160,6 +219,9 @@ export function parsePlaneManifest(value) {
160
219
  const laneScope = parseManifestLaneScope(record.laneScope);
161
220
  if (!laneScope.ok)
162
221
  return { ok: false, error: laneScope.error };
222
+ const snapshot = parseManifestSnapshot(record.snapshotPath, record.snapshotSha, record.repoRoot);
223
+ if (!snapshot.ok)
224
+ return { ok: false, error: snapshot.error };
163
225
  const members = [];
164
226
  const unrecognizedMemberNames = [];
165
227
  const seen = new Set();
@@ -215,6 +277,20 @@ export function parsePlaneManifest(value) {
215
277
  if (member.logPath !== relativeLogPathFor(member.name)) {
216
278
  return { ok: false, error: `manifest member '${member.name}' has an unexpected log path` };
217
279
  }
280
+ // BAPI-1362. `cwd`, when present, must be its own canonical absolute form:
281
+ // it is later compared against a real checkout path to decide whether a
282
+ // base-worktree fast-forward is safe, and a relative or `..`-bearing value
283
+ // could be made to match or to miss a path it does not name.
284
+ if (member.cwd !== undefined && !isCanonicalAbsolutePath(member.cwd)) {
285
+ return {
286
+ ok: false,
287
+ error: `manifest member '${member.name}' has a working directory that is not a canonical absolute path`,
288
+ };
289
+ }
290
+ if (member.restartFailure !== undefined &&
291
+ !RESTART_FAILURE_REASONS.has(member.restartFailure)) {
292
+ return { ok: false, error: `manifest member '${member.name}' has an invalid restart failure` };
293
+ }
218
294
  members.push({
219
295
  name: member.name,
220
296
  pid: member.pid,
@@ -222,6 +298,10 @@ export function parsePlaneManifest(value) {
222
298
  exitCode: member.exitCode,
223
299
  exitSignal: member.exitSignal,
224
300
  logPath: member.logPath,
301
+ ...(member.cwd !== undefined ? { cwd: member.cwd } : {}),
302
+ ...(member.restartFailure !== undefined
303
+ ? { restartFailure: member.restartFailure }
304
+ : {}),
225
305
  });
226
306
  }
227
307
  // Every listed member was unrecognized: there is nothing this build can act
@@ -255,9 +335,48 @@ export function parsePlaneManifest(value) {
255
335
  ? { lifecycle: record.lifecycle }
256
336
  : {}),
257
337
  ...(laneScope.scope !== undefined ? { laneScope: laneScope.scope } : {}),
338
+ ...(snapshot.snapshot !== undefined
339
+ ? { snapshotPath: snapshot.snapshot.path, snapshotSha: snapshot.snapshot.sha }
340
+ : {}),
258
341
  },
259
342
  };
260
343
  }
344
+ /**
345
+ * Validate the optional snapshot identity pair (BAPI-1362).
346
+ *
347
+ * Three rules, each a hard rejection rather than a coerced default:
348
+ *
349
+ * 1. The two fields arrive TOGETHER. A half-pinned record would let a consumer
350
+ * read "pinned" off one field while the other said nothing.
351
+ * 2. The SHA is a full lowercase commit id.
352
+ * 3. The path is EXACTLY `<repoRoot>/.bridge/plane/snapshots/<sha>`. The manifest
353
+ * is untrusted input that later steers a restart at a directory, so it is
354
+ * never allowed to name an arbitrary path — the only legal spelling is the one
355
+ * this build derives from the repository root and the SHA.
356
+ */
357
+ function parseManifestSnapshot(snapshotPath, snapshotSha, repoRoot) {
358
+ if (snapshotPath === undefined && snapshotSha === undefined)
359
+ return { ok: true };
360
+ if (snapshotPath === undefined) {
361
+ return { ok: false, error: "manifest snapshot SHA is present without its snapshot path" };
362
+ }
363
+ if (snapshotSha === undefined) {
364
+ return { ok: false, error: "manifest snapshot path is present without its snapshot SHA" };
365
+ }
366
+ if (!isFullCommitSha(snapshotSha)) {
367
+ return { ok: false, error: "manifest snapshot SHA is not a full commit id" };
368
+ }
369
+ if (typeof repoRoot !== "string" || !isCanonicalAbsolutePath(repoRoot)) {
370
+ return { ok: false, error: "manifest snapshot cannot be validated without a canonical repository root" };
371
+ }
372
+ if (snapshotPath !== getPlanePaths(repoRoot).snapshotPathFor(snapshotSha)) {
373
+ return {
374
+ ok: false,
375
+ error: "manifest snapshot path is not the canonical snapshot location for its SHA",
376
+ };
377
+ }
378
+ return { ok: true, snapshot: { path: snapshotPath, sha: snapshotSha } };
379
+ }
261
380
  /**
262
381
  * Validate the optional `laneScope` record (BAPI-1102).
263
382
  *
@@ -65,19 +65,82 @@ export const PLANE_RECONCILER_READINESS_TIMEOUT_MS = 120_000;
65
65
  * fail a lane that recovers on its second try.
66
66
  */
67
67
  export const PLANE_EXECUTOR_READINESS_TIMEOUT_MS = 90_000;
68
- /** Executable used for the Python members. Overridable for a non-default venv. */
69
- export function resolvePythonExecutable(env) {
68
+ /** Path of a virtualenv executable inside the operator checkout. */
69
+ function operatorVenvExecutable(lookup, name) {
70
+ return lookup.platform === "win32"
71
+ ? path.join(lookup.operatorRoot, ".venv", "Scripts", `${name}.exe`)
72
+ : path.join(lookup.operatorRoot, ".venv", "bin", name);
73
+ }
74
+ /**
75
+ * Executable used for the Python members.
76
+ *
77
+ * Precedence: the documented `BAPI_PLANE_PYTHON` override, then the operator
78
+ * checkout's absolute `.venv` interpreter when one is found, then the bare name.
79
+ * The last step is the historical behavior, kept for a machine that keeps its
80
+ * environment elsewhere: an ABSOLUTE path to a file that is not there would turn
81
+ * "uses whatever `python` is on PATH" into "every member fails to spawn".
82
+ */
83
+ export function resolvePythonExecutable(env, venv) {
70
84
  const configured = env.BAPI_PLANE_PYTHON;
71
- return typeof configured === "string" && configured.trim().length > 0
72
- ? configured.trim()
73
- : "python";
85
+ if (typeof configured === "string" && configured.trim().length > 0)
86
+ return configured.trim();
87
+ if (venv) {
88
+ const candidate = operatorVenvExecutable(venv, "python");
89
+ if (venv.fileExists(candidate))
90
+ return candidate;
91
+ }
92
+ return "python";
74
93
  }
75
- /** Executable used for the server member. */
76
- export function resolveUvicornExecutable(env) {
94
+ /** Executable used for the server member. Same precedence as {@link resolvePythonExecutable}. */
95
+ export function resolveUvicornExecutable(env, venv) {
77
96
  const configured = env.BAPI_PLANE_UVICORN;
78
- return typeof configured === "string" && configured.trim().length > 0
79
- ? configured.trim()
80
- : "uvicorn";
97
+ if (typeof configured === "string" && configured.trim().length > 0)
98
+ return configured.trim();
99
+ if (venv) {
100
+ const candidate = operatorVenvExecutable(venv, "uvicorn");
101
+ if (venv.fileExists(candidate))
102
+ return candidate;
103
+ }
104
+ return "uvicorn";
105
+ }
106
+ /**
107
+ * Drop import-path entries that point at the MOVING operator checkout from a
108
+ * snapshot member's `PYTHONPATH` (BAPI-1362).
109
+ *
110
+ * A Python member launched from a snapshot imports its application code from the
111
+ * snapshot because the interpreter puts the script directory (or, for uvicorn,
112
+ * the working directory) first on `sys.path`. An inherited `PYTHONPATH` entry
113
+ * that names the operator checkout would sit beside that and could still satisfy
114
+ * an import the snapshot does not — silently loading code that changes under the
115
+ * plane. Entries that are the operator checkout or inside it (other than the
116
+ * snapshot itself, which lives inside it) are removed; every unrelated entry is
117
+ * kept, so an operator's own library path is unaffected.
118
+ *
119
+ * Returns the environment to use. The input is never mutated.
120
+ */
121
+ export function sanitizeSnapshotPythonPath(env, operatorRoot, snapshotPath) {
122
+ const raw = env.PYTHONPATH;
123
+ if (typeof raw !== "string" || raw.length === 0)
124
+ return env;
125
+ const insideOperator = (entry) => {
126
+ const resolved = path.resolve(entry);
127
+ const relative = path.relative(operatorRoot, resolved);
128
+ if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) {
129
+ const toSnapshot = path.relative(snapshotPath, resolved);
130
+ const inSnapshot = toSnapshot === "" || (!toSnapshot.startsWith("..") && !path.isAbsolute(toSnapshot));
131
+ return !inSnapshot;
132
+ }
133
+ return false;
134
+ };
135
+ const kept = raw
136
+ .split(path.delimiter)
137
+ .filter((entry) => entry.length > 0 && !insideOperator(entry));
138
+ const next = { ...env };
139
+ if (kept.length === 0)
140
+ delete next.PYTHONPATH;
141
+ else
142
+ next.PYTHONPATH = kept.join(path.delimiter);
143
+ return next;
81
144
  }
82
145
  /** Highest port number a TCP endpoint can occupy. */
83
146
  const MAX_TCP_PORT = 65_535;
@@ -218,7 +281,19 @@ export function buildPlaneObserverEnv(childEnv) {
218
281
  export function buildPlaneMemberRoster(params) {
219
282
  const { context, executors, parentEnv, nodeExecutable } = params;
220
283
  const env = buildPlaneChildEnv(parentEnv, context);
284
+ // BAPI-1362 — the working directory the PYTHON members run from: the pinned
285
+ // snapshot when preflight prepared one, otherwise the operator checkout (the
286
+ // unpinned behavior every pre-snapshot caller has). Executor lanes are
287
+ // deliberately NOT moved: they run compiled Node from this repository's
288
+ // gitignored build tree by absolute path, which a snapshot does not contain and
289
+ // a checkout update does not change.
221
290
  const cwd = context.repoRoot;
291
+ const pythonCwd = context.snapshot?.path ?? context.repoRoot;
292
+ // Only a snapshot launch needs the import-path guard; an unpinned launch keeps
293
+ // the exact environment it always had.
294
+ const pythonEnv = (base) => context.snapshot
295
+ ? sanitizeSnapshotPythonPath(base, context.repoRoot, context.snapshot.path)
296
+ : base;
222
297
  // One resolved endpoint for the bind argv, the readiness probe, every
223
298
  // executor's `--base-url`, and the child `BAPI_BASE_URL`. Reading the default
224
299
  // constants here instead is what would let uvicorn bind one port while the
@@ -226,7 +301,7 @@ export function buildPlaneMemberRoster(params) {
226
301
  const endpoint = context.endpoint;
227
302
  const server = {
228
303
  name: "server",
229
- command: resolveUvicornExecutable(parentEnv),
304
+ command: resolveUvicornExecutable(parentEnv, params.venv),
230
305
  // NO `--reload`, at any layer, ever. `--reload` restarts the worker process
231
306
  // on file change and drops in-flight MCP calls, which during a conductor run
232
307
  // silently truncates long tool calls the executor is waiting on.
@@ -237,14 +312,14 @@ export function buildPlaneMemberRoster(params) {
237
312
  "--port",
238
313
  String(endpoint.port),
239
314
  ],
240
- cwd,
315
+ cwd: pythonCwd,
241
316
  // BAPI-1279 — the server member runs NO scheduler. `main.py` starts one
242
317
  // whenever DISABLE_SCHEDULER is unset, and that scheduler ticks the
243
318
  // reconciler: the plane would run two reconcilers of its own, and the
244
319
  // pre-worker foreign-reconciler guard would see this server's heartbeat
245
320
  // and refuse the plane's own worker. The `worker` member below is the one
246
321
  // reconciler, exactly as a Heroku web dyno leaves scheduling to `worker:`.
247
- env: { ...env, DISABLE_SCHEDULER: "true" },
322
+ env: pythonEnv({ ...env, DISABLE_SCHEDULER: "true" }),
248
323
  logPath: relativeLogPathFor("server"),
249
324
  readiness: {
250
325
  kind: "tcp",
@@ -283,19 +358,19 @@ export function buildPlaneMemberRoster(params) {
283
358
  */
284
359
  const observer = {
285
360
  name: "observer",
286
- command: resolvePythonExecutable(parentEnv),
361
+ command: resolvePythonExecutable(parentEnv, params.venv),
287
362
  args: ["worker.py"],
288
- cwd,
289
- env: buildPlaneObserverEnv(env),
363
+ cwd: pythonCwd,
364
+ env: pythonEnv(buildPlaneObserverEnv(env)),
290
365
  logPath: relativeLogPathFor("observer"),
291
366
  readiness: null,
292
367
  };
293
368
  const worker = {
294
369
  name: "worker",
295
- command: resolvePythonExecutable(parentEnv),
370
+ command: resolvePythonExecutable(parentEnv, params.venv),
296
371
  args: ["worker.py"],
297
- cwd,
298
- env,
372
+ cwd: pythonCwd,
373
+ env: pythonEnv(env),
299
374
  // The reconciler has no listening socket, so it used to be `readiness: null`
300
375
  // — "spawned and still alive", which cannot tell a booting reconciler from
301
376
  // one that crashed on its first tick. It is now gated on a FRESH durable
@@ -316,7 +316,9 @@ export async function runPlanePreflight(repoRoot, deps) {
316
316
  execFile: deps.execFile,
317
317
  fileExists: (filePath) => fileExists(filePath, deps.fs),
318
318
  platform: deps.platform,
319
- }));
319
+ },
320
+ // The code this plane will RUN. The executable still comes from `repoRoot`.
321
+ deps.codeSnapshot?.path ?? repoRoot));
320
322
  const existing = await checkExistingPlane(repoRoot, deps);
321
323
  record(existing);
322
324
  observations.existingPlane = existing.observation;
@@ -349,6 +351,7 @@ export async function runPlanePreflight(repoRoot, deps) {
349
351
  runtimeEntrypoint: runtimeEntrypoint.entrypoint,
350
352
  bridgeApiKey: credentials.apiKey,
351
353
  bridgeCredentialSource: credentials.source,
354
+ ...(deps.codeSnapshot !== undefined ? { snapshot: deps.codeSnapshot } : {}),
352
355
  },
353
356
  };
354
357
  }
@@ -903,7 +906,32 @@ const PLANE_UNREPORTED_REMEDIATION = "run `plane up` (or `epic-implementer readi
903
906
  * - A launch-permitting `warning` maps to `warn`, never `fail` and never `skip`,
904
907
  * and nothing here feeds back into the preflight's own `ok` calculation.
905
908
  */
906
- export function mapPlanePreflightToReadinessChecks(result) {
909
+ /**
910
+ * Fixed detail for each descriptor that does not apply to the target (BAPI-1360).
911
+ * Neither string carries a URL, port, or path: the target is only ever named by
912
+ * its kind.
913
+ */
914
+ const PLANE_NOT_APPLICABLE_DETAIL = {
915
+ remote: "not applicable: the target is a remote Bridge API, not a local plane",
916
+ unresolvable: "not applicable: endpoint could not be resolved from target URL",
917
+ };
918
+ export function mapPlanePreflightToReadinessChecks(result,
919
+ /**
920
+ * An EXPLICIT statement that the plane checks do not apply to this target. It is
921
+ * the only way a `skip` is produced here: a missing preflight result with no such
922
+ * input stays the failure it always was, so an unrun probe is never mistaken for
923
+ * an inapplicable one.
924
+ */
925
+ notApplicable) {
926
+ if (notApplicable !== undefined) {
927
+ return PLANE_READINESS_DESCRIPTORS.map(({ id, label }) => createReadinessCheck({
928
+ id: `plane.${id}`,
929
+ source: "plane",
930
+ label,
931
+ status: "skip",
932
+ detail: PLANE_NOT_APPLICABLE_DETAIL[notApplicable],
933
+ }));
934
+ }
907
935
  const byId = new Map();
908
936
  for (const outcome of result?.outcomes ?? []) {
909
937
  if (!byId.has(outcome.id))
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Actual working directory of a live process (BAPI-1362).
3
+ *
4
+ * The base-worktree fast-forward guard needs to know whether a plane member is
5
+ * running out of a given checkout. A v4 manifest records each member's `cwd`, but
6
+ * a legacy manifest records nothing, so the only evidence is the process itself.
7
+ *
8
+ * Closed result, never a throw, and never raw command output: the only string a
9
+ * caller can receive is a path that passed {@link isCanonicalAbsolutePath}.
10
+ * Anything this function cannot establish is `unverifiable`, and the guard reads
11
+ * that as "unsafe to merge", not as "somewhere else".
12
+ */
13
+ import { isCanonicalAbsolutePath } from "./manifest.js";
14
+ /** Positive integer pids only; anything else can never be placed in an argv. */
15
+ function isUsablePid(pid) {
16
+ return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0;
17
+ }
18
+ /**
19
+ * Parse `lsof -Fn` field output and return the path on its `n` line.
20
+ *
21
+ * Exactly one `n` line is accepted. Zero or several mean the output is not what
22
+ * this code understands, which is `unverifiable` rather than a guess.
23
+ */
24
+ export function parseLsofCwd(stdout) {
25
+ const names = stdout
26
+ .split(/\r?\n/)
27
+ .filter((line) => line.startsWith("n"))
28
+ .map((line) => line.slice(1));
29
+ if (names.length !== 1)
30
+ return null;
31
+ return isCanonicalAbsolutePath(names[0]) ? names[0] : null;
32
+ }
33
+ export async function readProcessCwd(pid, deps) {
34
+ if (!isUsablePid(pid))
35
+ return { kind: "unverifiable" };
36
+ try {
37
+ if (deps.platform === "linux") {
38
+ const target = await deps.readlink(`/proc/${pid}/cwd`);
39
+ return isCanonicalAbsolutePath(target) ? { kind: "cwd", cwd: target } : { kind: "unverifiable" };
40
+ }
41
+ if (deps.platform === "darwin") {
42
+ const result = await deps.execFile("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"]);
43
+ if (!result.ok)
44
+ return { kind: "unverifiable" };
45
+ const cwd = parseLsofCwd(result.stdout);
46
+ return cwd === null ? { kind: "unverifiable" } : { kind: "cwd", cwd };
47
+ }
48
+ }
49
+ catch {
50
+ /* every failure is the same closed answer */
51
+ }
52
+ return { kind: "unverifiable" };
53
+ }
54
+ /** Production bindings. The only place this module touches the platform. */
55
+ export async function createProcessCwdDeps() {
56
+ const { readlink } = await import("fs/promises");
57
+ const { execFile } = await import("child_process");
58
+ return {
59
+ platform: process.platform,
60
+ readlink: (linkPath) => readlink(linkPath),
61
+ execFile: (file, args) => new Promise((resolve) => {
62
+ execFile(file, args, { shell: false, windowsHide: true, timeout: 5_000 }, (error, stdout) => {
63
+ resolve(error ? { ok: false } : { ok: true, stdout: String(stdout) });
64
+ });
65
+ }),
66
+ };
67
+ }