@bridge_gpt/mcp-server 0.2.52 → 0.2.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +121 -15
  2. package/build/agent-launchers/claude.js +3 -3
  3. package/build/agent-launchers/prompt.js +8 -11
  4. package/build/base-ref.js +33 -9
  5. package/build/bounded-wait.js +174 -0
  6. package/build/commands.generated.js +7 -5
  7. package/build/conductor/bridge-api-client.js +97 -8
  8. package/build/conductor/cli.js +23 -0
  9. package/build/conductor/doctor.js +428 -5
  10. package/build/conductor/epic-runtime.js +133 -97
  11. package/build/conductor/install-doctor.js +65 -656
  12. package/build/conductor/readiness-cli.js +152 -0
  13. package/build/conductor/readiness-sections.js +666 -0
  14. package/build/conductor/readiness.js +795 -0
  15. package/build/conductor/run-branch.js +137 -0
  16. package/build/conductor/test-run-branch-vectors.js +165 -0
  17. package/build/conductor/tools.js +56 -3
  18. package/build/conductor-bin.js +21 -17
  19. package/build/doctor.js +68 -1
  20. package/build/drive-epic.js +287 -51
  21. package/build/executor/claim-scope.js +104 -0
  22. package/build/executor/cli.js +14 -25
  23. package/build/executor/env-file-guard.js +82 -3
  24. package/build/executor/job-runner.js +60 -0
  25. package/build/index.js +4496 -4697
  26. package/build/install-doctor.js +154 -2
  27. package/build/local-artifact-storage.js +130 -0
  28. package/build/pipelines.generated.js +17 -10
  29. package/build/plane/alembic-head.js +40 -11
  30. package/build/plane/build-freshness.js +22 -11
  31. package/build/plane/cli.js +285 -36
  32. package/build/plane/manifest.js +209 -1
  33. package/build/plane/member-roster.js +70 -0
  34. package/build/plane/preflight.js +363 -48
  35. package/build/plane/shutdown.js +14 -1
  36. package/build/plane/status.js +35 -1
  37. package/build/plane/supervisor.js +546 -164
  38. package/build/plane/types.js +61 -2
  39. package/build/polling-policy.js +72 -0
  40. package/build/readiness-check.js +412 -0
  41. package/build/readme.generated.js +1 -1
  42. package/build/review-generation.js +219 -0
  43. package/build/run-unit-tests-launcher.js +5 -0
  44. package/build/setup-epic.js +514 -23
  45. package/build/ticket-key-utils.js +4 -3
  46. package/build/ticket-review-artifact-gate.js +461 -0
  47. package/build/upgrade-cli.js +5 -26
  48. package/build/version.generated.js +3 -3
  49. package/docs/install/mcp-tool-integrations.md +23 -1
  50. package/package.json +2 -2
  51. package/pipelines/{full-automation.json → idea-to-pr.json} +1 -1
  52. package/pipelines/review-ticket.json +17 -4
@@ -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_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_RUNTIME_DIR, } from "./types.js";
21
21
  /**
22
22
  * Derive every runtime path beneath a validated repository root.
23
23
  *
@@ -54,7 +54,13 @@ const MANIFEST_KEYS = new Set([
54
54
  "members",
55
55
  // BAPI-872: the optional server-side epic-run binding.
56
56
  "epicRunId",
57
+ // BAPI-1102: the two-phase lifecycle state and the claim scope phase two
58
+ // starts its lanes with. Both optional; both absent on a v1/v2 manifest.
59
+ "lifecycle",
60
+ "laneScope",
57
61
  ]);
62
+ /** Every legal `lifecycle` value, as a set for the strict parse below. */
63
+ const LIFECYCLE_PHASES = new Set(PLANE_LIFECYCLE_PHASES);
58
64
  /** Non-blank string, trimmed equal to itself (no leading/trailing whitespace). */
59
65
  function isNonBlankTrimmedString(value) {
60
66
  return typeof value === "string" && value.trim().length > 0 && value === value.trim();
@@ -144,6 +150,16 @@ export function parsePlaneManifest(value) {
144
150
  if (record.epicRunId !== undefined && !isNonBlankTrimmedString(record.epicRunId)) {
145
151
  return { ok: false, error: "manifest epic run id is malformed" };
146
152
  }
153
+ // BAPI-1102: both are optional and both are validated STRICTLY when present.
154
+ // Absence means a v1/v2 manifest or a single-phase bring-up, and the reader
155
+ // resolves absence to `"ready"` — an older build only ever wrote a manifest for
156
+ // a plane whose whole roster had started.
157
+ if (record.lifecycle !== undefined && !LIFECYCLE_PHASES.has(record.lifecycle)) {
158
+ return { ok: false, error: "manifest lifecycle phase is malformed" };
159
+ }
160
+ const laneScope = parseManifestLaneScope(record.laneScope);
161
+ if (!laneScope.ok)
162
+ return { ok: false, error: laneScope.error };
147
163
  const members = [];
148
164
  const unrecognizedMemberNames = [];
149
165
  const seen = new Set();
@@ -235,9 +251,63 @@ export function parsePlaneManifest(value) {
235
251
  updatedAt: record.updatedAt,
236
252
  members,
237
253
  ...(record.epicRunId !== undefined ? { epicRunId: record.epicRunId } : {}),
254
+ ...(record.lifecycle !== undefined
255
+ ? { lifecycle: record.lifecycle }
256
+ : {}),
257
+ ...(laneScope.scope !== undefined ? { laneScope: laneScope.scope } : {}),
238
258
  },
239
259
  };
240
260
  }
261
+ /**
262
+ * Validate the optional `laneScope` record (BAPI-1102).
263
+ *
264
+ * Strict, and strict for a specific reason: this value crosses a PROCESS
265
+ * boundary. The composed caller writes it and the detached runtime reads it, so
266
+ * it is the one place an untrusted-looking value can become executor argv. Every
267
+ * shape is checked before it is trusted, and a malformed value is a parse
268
+ * failure rather than a coerced default.
269
+ *
270
+ * It carries run ids and a discriminant only. No credential, environment object,
271
+ * or argv array may ever be added here — the manifest lives on disk for the
272
+ * lifetime of the plane, which is the reason the record has always been free of
273
+ * secrets.
274
+ */
275
+ function parseManifestLaneScope(value) {
276
+ if (value === undefined)
277
+ return { ok: true };
278
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
279
+ return { ok: false, error: "manifest lane scope is not an object" };
280
+ }
281
+ const record = value;
282
+ if (record.kind === "repo-wide") {
283
+ if (Object.keys(record).length !== 1) {
284
+ return { ok: false, error: "manifest lane scope has unsupported fields" };
285
+ }
286
+ return { ok: true, scope: { kind: "repo-wide" } };
287
+ }
288
+ if (record.kind !== "epic-runs") {
289
+ return { ok: false, error: "manifest lane scope kind is malformed" };
290
+ }
291
+ if (Object.keys(record).length !== 2 || !Array.isArray(record.epicRunIds)) {
292
+ return { ok: false, error: "manifest lane scope epic run ids are malformed" };
293
+ }
294
+ const ids = record.epicRunIds;
295
+ if (ids.length === 0 || !ids.every((id) => isNonBlankTrimmedString(id))) {
296
+ return { ok: false, error: "manifest lane scope epic run ids are malformed" };
297
+ }
298
+ return { ok: true, scope: { kind: "epic-runs", epicRunIds: ids } };
299
+ }
300
+ /**
301
+ * The phase this manifest describes, resolving ABSENCE to `"ready"`.
302
+ *
303
+ * The single reader of that rule. A manifest written before BAPI-1102 describes a
304
+ * plane whose entire roster started before the file was last written, so `ready`
305
+ * is the truthful reading — and it is the reading that keeps `plane status` and
306
+ * `plane down` behaving identically on a legacy plane.
307
+ */
308
+ export function planeManifestLifecycle(manifest) {
309
+ return manifest.lifecycle ?? "ready";
310
+ }
241
311
  /**
242
312
  * Read and validate the manifest, distinguishing every outcome.
243
313
  *
@@ -463,6 +533,144 @@ export async function bindPlaneManifestEpicRun(repoRoot, planeId, epicRunId, fs)
463
533
  }
464
534
  return { ok: true, alreadyBound: false };
465
535
  }
536
+ /**
537
+ * Read + validate + identity-check the manifest for a guarded write.
538
+ *
539
+ * Shared by every operation below so the three refusals that precede any mutation
540
+ * are stated once. A caller that wrote its own version of this would eventually
541
+ * mutate a record it had not validated.
542
+ */
543
+ async function readOwnedManifest(repoRoot, planeId, fs) {
544
+ const read = await readPlaneManifest(repoRoot, fs);
545
+ if (read.kind === "missing") {
546
+ return {
547
+ ok: false,
548
+ reason: "no-manifest",
549
+ message: "no plane manifest found for this repository",
550
+ };
551
+ }
552
+ if (read.kind !== "valid") {
553
+ return {
554
+ ok: false,
555
+ reason: "unvalidated-manifest",
556
+ message: `plane manifest could not be validated (${read.error})`,
557
+ };
558
+ }
559
+ if (read.manifest.planeId !== planeId || read.manifest.repoRoot !== repoRoot) {
560
+ return {
561
+ ok: false,
562
+ reason: "identity-mismatch",
563
+ message: "plane manifest belongs to a different plane identity",
564
+ };
565
+ }
566
+ return { ok: true, manifest: read.manifest };
567
+ }
568
+ /** Persist a manifest mutation, converting a filesystem failure into a result. */
569
+ async function writeGuardedManifest(manifest, fs) {
570
+ try {
571
+ const updated = { ...manifest, updatedAt: new Date().toISOString() };
572
+ await writePlaneManifest(updated, fs);
573
+ return { ok: true, manifest: updated };
574
+ }
575
+ catch (err) {
576
+ // The CODE only. A thrown filesystem error carries a path, and this message
577
+ // reaches an operator's terminal.
578
+ const code = err?.code;
579
+ return {
580
+ ok: false,
581
+ reason: "error",
582
+ message: `plane manifest could not be written${code ? ` (${code})` : ""}`,
583
+ };
584
+ }
585
+ }
586
+ /**
587
+ * Move the plane from one lifecycle phase to the next, atomically.
588
+ *
589
+ * `expected` is REQUIRED and is the concurrency control. Writing the phase
590
+ * unconditionally would let a stale writer — a retried request, or a runtime that
591
+ * resumed after a pause — rewind a plane that has already progressed, which for
592
+ * `lanes-ready` → `control-plane-ready` would mean the runtime waiting forever
593
+ * for a phase-two request that had already been served.
594
+ */
595
+ export async function transitionPlaneManifestLifecycle(repoRoot, planeId, expected, next, fs) {
596
+ const owned = await readOwnedManifest(repoRoot, planeId, fs);
597
+ if (!owned.ok)
598
+ return owned;
599
+ const current = planeManifestLifecycle(owned.manifest);
600
+ if (current !== expected) {
601
+ return {
602
+ ok: false,
603
+ reason: "phase-conflict",
604
+ message: `plane is ${current}, not ${expected}`,
605
+ };
606
+ }
607
+ return writeGuardedManifest({ ...owned.manifest, lifecycle: next }, fs);
608
+ }
609
+ /**
610
+ * Request phase two: bind the run and record the claim scope the lanes will use.
611
+ *
612
+ * The single writer of `laneScope`, and it binds `epicRunId` in the SAME atomic
613
+ * write. Doing them together is what makes "one composed plane serves exactly one
614
+ * run" hold: a plane already bound to a different run is refused here, before any
615
+ * lane exists, rather than being discovered after lanes are already claiming for
616
+ * the wrong run.
617
+ *
618
+ * A composed plane is deliberately SINGLE-RUN. Appending a second run scope to
619
+ * live lanes was considered and rejected: the lanes are already running with a
620
+ * fixed argv, so a second scope would require restarting them, and a restart
621
+ * mid-run is indistinguishable to the reconciler from a lane crash. A second epic
622
+ * gets a second plane.
623
+ *
624
+ * Re-requesting the SAME run is an idempotent no-op when the plane has already
625
+ * moved past `control-plane-ready`, so a retried composed bring-up does not
626
+ * refuse a plane it already set up.
627
+ */
628
+ export async function requestPlaneManifestLanes(repoRoot, planeId, epicRunId, laneScope, fs) {
629
+ const owned = await readOwnedManifest(repoRoot, planeId, fs);
630
+ if (!owned.ok)
631
+ return owned;
632
+ const manifest = owned.manifest;
633
+ if (manifest.epicRunId !== undefined && manifest.epicRunId !== epicRunId) {
634
+ return {
635
+ ok: false,
636
+ reason: "run-conflict",
637
+ message: `plane is already bound to a different epic run (${manifest.epicRunId})`,
638
+ };
639
+ }
640
+ const current = planeManifestLifecycle(manifest);
641
+ if (current !== "control-plane-ready") {
642
+ // Already served for THIS run: report success rather than a conflict, so a
643
+ // retry of the composed flow converges instead of failing on its own work.
644
+ if (manifest.epicRunId === epicRunId && manifest.laneScope !== undefined) {
645
+ return { ok: true, manifest };
646
+ }
647
+ return {
648
+ ok: false,
649
+ reason: "phase-conflict",
650
+ message: `plane is ${current}, not control-plane-ready`,
651
+ };
652
+ }
653
+ return writeGuardedManifest({ ...manifest, epicRunId, laneScope }, fs);
654
+ }
655
+ /**
656
+ * Append members to a live manifest, guarded by plane identity.
657
+ *
658
+ * Used by the detached runtime as each phase-two cohort begins spawning, so
659
+ * `plane status` and `plane down` can see and signal members that did not exist
660
+ * when the manifest was first claimed. A name already listed is REPLACED rather
661
+ * than duplicated: the manifest's own parse rejects a repeated member name, and
662
+ * producing a record this module's reader would refuse is never the right way to
663
+ * report progress.
664
+ */
665
+ export async function appendPlaneManifestMembers(repoRoot, planeId, members, fs) {
666
+ const owned = await readOwnedManifest(repoRoot, planeId, fs);
667
+ if (!owned.ok)
668
+ return owned;
669
+ const byName = new Map(owned.manifest.members.map((m) => [m.name, m]));
670
+ for (const member of members)
671
+ byName.set(member.name, member);
672
+ return writeGuardedManifest({ ...owned.manifest, members: [...byName.values()] }, fs);
673
+ }
466
674
  /**
467
675
  * Take exclusive ownership of `.bridge/plane/plane.json`.
468
676
  *
@@ -25,6 +25,7 @@
25
25
  import path from "path";
26
26
  import { PLANE_OBSERVER_MODE_ENV, PLANE_OBSERVER_MODE_VALUE, PLANE_SERVER_HOST, PLANE_SERVER_PORT, PLANE_SERVER_PORT_ENV_VAR, } from "./types.js";
27
27
  import { relativeLogPathFor } from "./manifest.js";
28
+ import { EXECUTOR_CLAIM_SCOPE_REQUIRED_MESSAGE, executorClaimScopeArgs, } from "../executor/claim-scope.js";
28
29
  /** How long a member with a readiness probe gets to start listening. */
29
30
  export const PLANE_READINESS_TIMEOUT_MS = 60_000;
30
31
  /**
@@ -310,6 +311,18 @@ export function buildPlaneMemberRoster(params) {
310
311
  timeoutMs: PLANE_RECONCILER_READINESS_TIMEOUT_MS,
311
312
  },
312
313
  };
314
+ // BAPI-1102 — refused HERE, not merely documented. A lane built without a
315
+ // scope is a lane that exits at startup (BAPI-1026), and a roster that could
316
+ // produce one would turn a missing flag into a member crash and a full plane
317
+ // rollback. The public parser already refuses first; this is the structural
318
+ // backstop for every internal caller.
319
+ if (executors > 0 && params.claimScope === undefined) {
320
+ throw new Error(EXECUTOR_CLAIM_SCOPE_REQUIRED_MESSAGE);
321
+ }
322
+ // Built ONCE, outside the lane loop: every lane of one plane serves the same
323
+ // scope, and rendering it per-lane would be a chance for two lanes of the same
324
+ // plane to disagree.
325
+ const scopeArgs = params.claimScope === undefined ? [] : executorClaimScopeArgs(params.claimScope);
313
326
  const executorMembers = [];
314
327
  for (let lane = 1; lane <= executors; lane += 1) {
315
328
  const name = `executor-${lane}`;
@@ -334,6 +347,9 @@ export function buildPlaneMemberRoster(params) {
334
347
  endpoint.baseUrl,
335
348
  "--executor-id",
336
349
  executorId,
350
+ // Appended LAST and rendered by the executor's OWN argv builder, so the
351
+ // flag spelling here cannot drift from the parser that reads it.
352
+ ...scopeArgs,
337
353
  ],
338
354
  cwd,
339
355
  env,
@@ -361,6 +377,60 @@ export function buildPlaneMemberRoster(params) {
361
377
  }
362
378
  return [server, worker, ...executorMembers, observer];
363
379
  }
380
+ // ---------------------------------------------------------------------------
381
+ // BAPI-1102 — the two-phase roster selectors.
382
+ // ---------------------------------------------------------------------------
383
+ // Each is a FILTER over the full roster rather than a second builder, and that
384
+ // is the whole design. Every spec — argv, environment, log path, readiness gate,
385
+ // the executor id minted once and used twice — keeps exactly one definition, so
386
+ // a phase roster cannot drift from the roster the single-phase path builds. It
387
+ // also makes the composition law checkable and cheap:
388
+ //
389
+ // [...controlPlane, ...executorLanes, ...observer] === buildPlaneMemberRoster(...)
390
+ //
391
+ // which a unit test asserts, so the split can never silently drop or reorder a
392
+ // member. A pair of independent builders would have needed that property
393
+ // re-established by inspection after every future edit.
394
+ /** Is this the name of an executor lane? */
395
+ function isExecutorLaneName(name) {
396
+ return name.startsWith("executor-");
397
+ }
398
+ /**
399
+ * Phase one: the server and the reconciler worker, in that order.
400
+ *
401
+ * ZERO executor lanes, so no claim scope is required or accepted — which is the
402
+ * one legitimate zero-lane roster in the system. It is INTERNAL: there is no
403
+ * public `plane up --executors 0`, because "start a plane that can never do any
404
+ * work" is not a state an operator should be able to ask for by hand.
405
+ *
406
+ * The observer is deliberately NOT here. See {@link PlaneLifecyclePhase}.
407
+ */
408
+ export function buildPlaneControlPlaneRoster(params) {
409
+ return buildPlaneMemberRoster({ ...params, executors: 0 }).filter((member) => member.name === "server" || member.name === "worker");
410
+ }
411
+ /**
412
+ * Phase two, part one: the scoped executor lanes.
413
+ *
414
+ * `claimScope` is REQUIRED by the type, not merely by the runtime check inside
415
+ * the builder — a lane roster is the one thing that cannot be built without a
416
+ * scope, and making that a compile-time fact removes the whole class of caller
417
+ * that forgets.
418
+ */
419
+ export function buildPlaneExecutorLaneRoster(params) {
420
+ return buildPlaneMemberRoster(params).filter((member) => isExecutorLaneName(member.name));
421
+ }
422
+ /**
423
+ * Phase two, part two: the dead-man observer, started LAST.
424
+ *
425
+ * Split out so the runtime can start it only after every lane's per-instance
426
+ * heartbeat gate has closed. Starting it at control-plane time would put it on a
427
+ * plane with zero executor lanes for however long run creation takes — precisely
428
+ * the state its executor-component sweep would alert on, and precisely the boot
429
+ * noise the ordering contract above exists to prevent.
430
+ */
431
+ export function buildPlaneObserverRoster(params) {
432
+ return buildPlaneMemberRoster({ ...params, executors: 0 }).filter((member) => member.name === "observer");
433
+ }
364
434
  /**
365
435
  * Deterministic, distinct, CLI-safe executor id for one lane.
366
436
  *