@bridge_gpt/mcp-server 0.2.59 → 0.2.62

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 (78) hide show
  1. package/README.md +181 -51
  2. package/build/agent-guidance.generated.js +1 -1
  3. package/build/agent-notes.js +7 -1
  4. package/build/commands.generated.js +6 -6
  5. package/build/conductor/bridge-api-client.js +406 -4
  6. package/build/conductor/doctor.js +255 -53
  7. package/build/conductor/epic-runtime.js +11 -2
  8. package/build/conductor/install-doctor.js +24 -2
  9. package/build/conductor/local-merge.js +319 -26
  10. package/build/conductor/merge-execution-contract.js +86 -0
  11. package/build/conductor/merge-execution-eligibility.js +130 -0
  12. package/build/conductor/readiness-sections.js +256 -16
  13. package/build/conductor/readiness.js +203 -29
  14. package/build/conductor/recovery-operations.js +8 -2
  15. package/build/connect-bitbucket-api.js +6 -1
  16. package/build/connect-bitbucket.js +398 -88
  17. package/build/credential-store.js +172 -0
  18. package/build/docs.generated.js +2 -2
  19. package/build/doctor.js +277 -15
  20. package/build/drive-epic.js +147 -12
  21. package/build/epic-implementer-bin.js +15 -15
  22. package/build/epic-implementer-claude-hook-bin.js +1 -1
  23. package/build/epic-implementer-env.js +43 -14
  24. package/build/epic-integration-pr.js +61 -15
  25. package/build/epic-launch-profile.js +455 -0
  26. package/build/executor/ci-rerun-job.js +269 -0
  27. package/build/executor/cli.js +40 -0
  28. package/build/executor/credentials.js +28 -1
  29. package/build/executor/env.js +60 -0
  30. package/build/executor/http-client.js +45 -1
  31. package/build/executor/index.js +21 -4
  32. package/build/executor/integration-job.js +759 -0
  33. package/build/executor/job-runner.js +218 -9
  34. package/build/executor/job-types.js +12 -2
  35. package/build/executor/merge-job.js +11 -2
  36. package/build/executor/observation.js +114 -7
  37. package/build/executor/runner.js +101 -1
  38. package/build/executor/service-lifecycle.js +136 -0
  39. package/build/executor/service-unit.js +427 -9
  40. package/build/executor/types.js +48 -0
  41. package/build/index.js +479 -240
  42. package/build/install-bridge-conductor.js +599 -14
  43. package/build/install-bridge.js +448 -46
  44. package/build/mcp-install-state.js +42 -4
  45. package/build/merge-pull-request.js +103 -10
  46. package/build/merge-workflow-codeowners.js +224 -0
  47. package/build/merge-workflow-config.js +187 -0
  48. package/build/merge-workflow-drift.js +97 -0
  49. package/build/merge-workflow-install.js +638 -0
  50. package/build/merge-workflow.js +956 -0
  51. package/build/pipelines.generated.js +3 -3
  52. package/build/plane/cli.js +24 -2
  53. package/build/plane/preflight.js +68 -0
  54. package/build/plane/supervisor.js +6 -0
  55. package/build/readme.generated.js +1 -1
  56. package/build/run-unit-tests-launcher.js +5 -0
  57. package/build/setup-epic.js +263 -38
  58. package/build/version.generated.js +3 -3
  59. package/package.json +3 -3
  60. package/build/conduct-epic/bridge-client.js +0 -773
  61. package/build/conduct-epic/checkpoint-store.js +0 -542
  62. package/build/conduct-epic/cli.js +0 -3145
  63. package/build/conduct-epic/cut-protocol.js +0 -392
  64. package/build/conduct-epic/lock.js +0 -302
  65. package/build/conduct-epic/pr-state.js +0 -286
  66. package/build/conduct-epic/spawn.js +0 -113
  67. package/build/conductor/cli.js +0 -1278
  68. package/build/conductor-bundle-cli.js +0 -256
  69. package/build/direct-ticket-tools.js +0 -70
  70. package/build/executor/reconcile-overlap-governance.js +0 -129
  71. package/build/implement-epic/bridge-client.js +0 -773
  72. package/build/implement-epic/checkpoint-store.js +0 -542
  73. package/build/implement-epic/cli.js +0 -3158
  74. package/build/implement-epic/cut-protocol.js +0 -392
  75. package/build/implement-epic/lock.js +0 -302
  76. package/build/implement-epic/pr-state.js +0 -286
  77. package/build/implement-epic/spawn.js +0 -113
  78. package/build/plan-epic-conductor-eligibility.js +0 -213
@@ -54,6 +54,32 @@
54
54
  * Neither can produce `complete`: they provision LOCAL capability, and the
55
55
  * terminal outcome still turns only on the live server-observed executor claim.
56
56
  *
57
+ * DURABLE-HOST MODE (BAPI-1137). The executor phase below is the supported
58
+ * bring-up for a *durable host*: an execution home that is not an interactive
59
+ * shell session, so a sleeping or closed operator laptop no longer stalls a run.
60
+ * The client command spelling is unchanged — it is still `install conductor` —
61
+ * and so is every primitive it drives; what this slice does is name the
62
+ * composition and make its boundaries explicit. Durable-host mode IS that
63
+ * composition, and nothing more:
64
+ *
65
+ * planning (pure)
66
+ * → separately consented unit writing
67
+ * → separately consented start
68
+ * → read-only local inspection (service state, session/linger durability)
69
+ * → bounded server-observed readiness
70
+ *
71
+ * `executor install-service` remains the standalone, lifecycle-free generator
72
+ * for operators who deliberately manage lifecycle themselves.
73
+ *
74
+ * HOST OWNERSHIP is an operational selection, not server policy. A durable host
75
+ * may be operator-operated or Bridge-operated, chosen per repository, and both
76
+ * choices run IDENTICAL generation and lifecycle logic: `BAPI_BASE_URL` is the
77
+ * only ownership/location input, while platform paths, unit format, and `PATH`
78
+ * are machine-specific details rather than ownership signals. Nothing about that
79
+ * choice reaches `RunPolicy` or any other server policy payload. Bridge-operated
80
+ * mode is also NON-ALLOCATING: this command can configure a host that has
81
+ * already been assigned, and it never provisions a fleet or allocates a machine.
82
+ *
57
83
  * The executor phase (BAPI-779) is opt-in via `--executor-id` and runs behind
58
84
  * TWO independent consents, per property 3: writing the unit is not consent to
59
85
  * start a persistent daemon. Its shape is:
@@ -70,6 +96,13 @@
70
96
  * non-fatal: the run prints the exact platform-specific start command and
71
97
  * finishes at `complete-with-manual-steps`.
72
98
  *
99
+ * MERGE AUTHORITY IS UNTOUCHED (BAPI-1137). Provisioning a durable host gives
100
+ * the executor no merge-decision route and no direct GitHub write operation: the
101
+ * reconciler remains the sole transition authority and the actuator writes only
102
+ * evidence. Readiness output above must never be worded so as to imply
103
+ * otherwise — `executor_ready` means an executor is claiming work, not that this
104
+ * host can merge anything.
105
+ *
73
106
  * The command does NOT persist a repository-level review policy: that is
74
107
  * BAPI-694's. The policy selected here is per-run and is reported as such.
75
108
  */
@@ -77,10 +110,17 @@ import { bootstrapConductorSupervisorDefaults, ConductorBridgeApiError, fetchCon
77
110
  import { CONDUCTOR_OPERATOR_RUNBOOK_POINTER, EXECUTOR_PROVISIONING_GUIDANCE, conductorInstallDoctorExitCode, conductorInstallDoctorHasFatal, formatConductorInstallDoctorReport, } from "./conductor/install-doctor.js";
78
111
  import { describeNativeLedgerAvailability, } from "./conductor/doctor.js";
79
112
  import { CONDUCTOR_BIN_UNRESOLVED_ERROR, MANAGED_HOOK_NAMES, } from "./conductor/git-hooks.js";
113
+ import os from "node:os";
114
+ import path from "node:path";
115
+ import { readFile as readFileAsync, stat as statAsync } from "node:fs/promises";
116
+ import { bindEpicLaunchCredential, readStoredBinding, } from "./credential-store.js";
117
+ import { EPIC_LAUNCH_EXECUTION_MODES, EPIC_LAUNCH_PROFILE_NAME_PATTERN, EPIC_LAUNCH_PROFILE_VERSION, createNodeEpicLaunchStoreDeps, readEpicLaunchProfile, renderEpicLaunchCommand, resolveEpicLaunchTarget, writeEpicLaunchProfile, } from "./epic-launch-profile.js";
118
+ import { DEFAULT_BAPI_BASE_URL } from "./executor/credentials.js";
80
119
  import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
81
120
  import { CLAUDE_REVIEW_WORKFLOW_RELPATH, renderClaudeReviewWorkflow, } from "./claude-review-workflow.js";
82
121
  import { formatExecutorInstallPreflight, } from "./executor/install-preflight.js";
83
122
  import { buildExecutorServiceStartPlan, formatExecutorServiceStartFailure, } from "./executor/service-lifecycle.js";
123
+ import { executorEpicRunIdRefusal, isValidExecutorEpicRunId, } from "./executor/claim-scope.js";
84
124
  import { planExecutorServiceInstall, } from "./executor/service-unit.js";
85
125
  // ---------------------------------------------------------------------------
86
126
  // Options + parsing
@@ -117,10 +157,28 @@ export function getInstallBridgeConductorUsage() {
117
157
  " --executor-id <id> Stable executor id. Supplying it OPTS IN to the",
118
158
  " consented executor-provisioning phase below; the",
119
159
  " phase is skipped entirely without it.",
160
+ " --epic-run-id <id> Claim scope for the generated unit (repeatable).",
161
+ " --repo-wide Claim-scope opt-out: claim from every authorized",
162
+ " repo, unscoped by run. EXACTLY ONE of --epic-run-id",
163
+ " or --repo-wide is required with --executor-id — the",
164
+ " executor refuses to start without one, so a unit",
165
+ " generated without it would fail on every boot.",
120
166
  " --allowed-bot <slug> claude-review workflow: allowed bot login",
121
167
  " --secret-name <NAME> claude-review workflow: OAuth token secret NAME",
122
168
  " --branches a,b claude-review workflow: PR branch triggers",
123
169
  " --skip-workflow Never offer the claude-review workflow",
170
+ " --launch-profile <name> (BAPI-1260) Opt in to the reusable epic launch: after",
171
+ " every mandatory prerequisite is verified, persist a",
172
+ " host-local profile and print the ONE drive-epic",
173
+ " command that selects it (runs in a fresh shell).",
174
+ " --base-url <url> Explicit Bridge API target for --launch-profile.",
175
+ " Required there (or BAPI_BASE_URL): nothing defaults",
176
+ " to production; select it deliberately.",
177
+ " --execution-mode <mode> local | durable (default: durable with",
178
+ " --executor-id, otherwise local).",
179
+ " --policy-file <path> Run policy the profile references; an unattended",
180
+ " run needs notify.webhook_url reachable from the",
181
+ " API server (notify.local_sink is not enough).",
124
182
  " --dry-run Diagnose and preview only. Performs NO writes:",
125
183
  " no POST, no file write, no host MCP config write,",
126
184
  " no Git hook write, no install-state or capability",
@@ -152,8 +210,17 @@ export function getInstallBridgeConductorUsage() {
152
210
  "hook consent; a degraded ledger reports the module, NODE_MODULE_VERSION, and",
153
211
  "failure kind, and never triggers an install, rebuild, or migration.",
154
212
  "",
155
- "Executor provisioning: `executor install-service` still generates a unit and",
156
- "nothing else, and remains available for operators who want the file only.",
213
+ "Durable-host mode: this command is the supported bring-up for a persistent,",
214
+ "laptop-independent executor. It composes planning, separately consented unit",
215
+ "writing, separately consented start, read-only local inspection, and bounded",
216
+ "server-observed readiness. `executor install-service` still generates a unit and",
217
+ "nothing else, and remains available for operators who manage lifecycle themselves.",
218
+ "",
219
+ "Host ownership (operator-operated or Bridge-operated) is an operational selection,",
220
+ "not server policy: both run identical generation and lifecycle logic, and",
221
+ "BAPI_BASE_URL is the only ownership/location input. Bridge-operated mode allocates",
222
+ "no host — it configures one already assigned. A repository with no assigned durable",
223
+ "host is NOT ready; escalate that to Bridge operations.",
157
224
  "",
158
225
  "With --executor-id, this command does the whole leg. A preflight checks the platform,",
159
226
  "`wt` / `git` / `claude` on PATH, and an explicit base URL, then reports whether",
@@ -200,10 +267,16 @@ export function parseInstallBridgeConductorArgs(argv) {
200
267
  let epicKey;
201
268
  let planFile;
202
269
  let executorId;
270
+ const epicRunIds = [];
271
+ let repoWide = false;
203
272
  let allowedBot;
204
273
  let secretName;
205
274
  let branches;
206
275
  const requiredChecks = [];
276
+ let launchProfile;
277
+ let baseUrl;
278
+ let executionMode;
279
+ let policyFile;
207
280
  const takeValue = (arg, inline, next) => {
208
281
  if (inline !== undefined) {
209
282
  if (inline.trim().length === 0)
@@ -231,6 +304,11 @@ export function parseInstallBridgeConductorArgs(argv) {
231
304
  return { status: "error", message: "--skip-workflow takes no value" };
232
305
  skipWorkflow = true;
233
306
  break;
307
+ case "--repo-wide":
308
+ if (inline !== undefined)
309
+ return { status: "error", message: "--repo-wide takes no value" };
310
+ repoWide = true;
311
+ break;
234
312
  case "--repo":
235
313
  case "--required-check":
236
314
  case "--required-checks":
@@ -238,9 +316,14 @@ export function parseInstallBridgeConductorArgs(argv) {
238
316
  case "--epic-key":
239
317
  case "--plan-file":
240
318
  case "--executor-id":
319
+ case "--epic-run-id":
241
320
  case "--allowed-bot":
242
321
  case "--secret-name":
243
- case "--branches": {
322
+ case "--branches":
323
+ case "--launch-profile":
324
+ case "--base-url":
325
+ case "--execution-mode":
326
+ case "--policy-file": {
244
327
  const taken = takeValue(flag, inline, argv[i + 1]);
245
328
  if (!taken.ok)
246
329
  return { status: "error", message: taken.error };
@@ -259,10 +342,34 @@ export function parseInstallBridgeConductorArgs(argv) {
259
342
  planFile = value;
260
343
  else if (flag === "--executor-id")
261
344
  executorId = value;
345
+ else if (flag === "--epic-run-id")
346
+ epicRunIds.push(value);
262
347
  else if (flag === "--allowed-bot")
263
348
  allowedBot = value;
264
349
  else if (flag === "--secret-name")
265
350
  secretName = value;
351
+ else if (flag === "--launch-profile") {
352
+ if (!EPIC_LAUNCH_PROFILE_NAME_PATTERN.test(value)) {
353
+ return {
354
+ status: "error",
355
+ message: "--launch-profile must match [a-z0-9][a-z0-9._-]{0,62}.",
356
+ };
357
+ }
358
+ launchProfile = value;
359
+ }
360
+ else if (flag === "--base-url")
361
+ baseUrl = value;
362
+ else if (flag === "--policy-file")
363
+ policyFile = value;
364
+ else if (flag === "--execution-mode") {
365
+ if (!EPIC_LAUNCH_EXECUTION_MODES.includes(value)) {
366
+ return {
367
+ status: "error",
368
+ message: `Invalid --execution-mode '${value}'. Expected one of: ${EPIC_LAUNCH_EXECUTION_MODES.join(", ")}`,
369
+ };
370
+ }
371
+ executionMode = value;
372
+ }
266
373
  else if (flag === "--branches") {
267
374
  const list = splitList(value);
268
375
  if (list.length === 0)
@@ -304,6 +411,61 @@ export function parseInstallBridgeConductorArgs(argv) {
304
411
  "so the bootstrap refuses to write one.",
305
412
  };
306
413
  }
414
+ // BAPI-1137 — the claim scope is validated HERE, at parse time, for the same
415
+ // reason the required-check list is: a unit that cannot start is worse than a
416
+ // refused invocation, and discovering it costs a reboot to notice.
417
+ if (executorId !== undefined) {
418
+ if (epicRunIds.length > 0 && repoWide) {
419
+ return {
420
+ status: "error",
421
+ message: "--epic-run-id and --repo-wide are mutually exclusive: a generated unit carries exactly " +
422
+ "one claim scope.",
423
+ };
424
+ }
425
+ if (epicRunIds.length === 0 && !repoWide) {
426
+ return {
427
+ status: "error",
428
+ message: "--executor-id requires an explicit claim scope: pass --epic-run-id <id> (repeatable) or " +
429
+ "--repo-wide. The executor refuses to start without one, so a unit generated without it " +
430
+ "would install cleanly and then fail on every boot.",
431
+ };
432
+ }
433
+ const seenRunIds = new Set();
434
+ for (const runId of epicRunIds) {
435
+ // The SAME shape check `executor install-service` applies, reused rather
436
+ // than restated: a value that reaches the rendered argument vector is
437
+ // validated at the point of entry, so a malformed id is one refusal here
438
+ // instead of a unit that installs and then fails on every boot.
439
+ if (!isValidExecutorEpicRunId(runId)) {
440
+ return { status: "error", message: executorEpicRunIdRefusal(runId) };
441
+ }
442
+ if (seenRunIds.has(runId)) {
443
+ return { status: "error", message: `Duplicate --epic-run-id: ${runId}` };
444
+ }
445
+ seenRunIds.add(runId);
446
+ }
447
+ }
448
+ else if (epicRunIds.length > 0 || repoWide) {
449
+ return {
450
+ status: "error",
451
+ message: "--epic-run-id / --repo-wide configure the generated executor unit, so they require " +
452
+ "--executor-id.",
453
+ };
454
+ }
455
+ // BAPI-1260 — the launch-profile inputs only mean something together.
456
+ if (launchProfile === undefined && (baseUrl !== undefined || executionMode !== undefined)) {
457
+ return {
458
+ status: "error",
459
+ message: "--base-url and --execution-mode configure an epic launch profile, so they require --launch-profile.",
460
+ };
461
+ }
462
+ if (executionMode === "durable" && executorId === undefined) {
463
+ return {
464
+ status: "error",
465
+ message: "--execution-mode durable requires --executor-id: a durable launch is served by a provisioned " +
466
+ "executor service, and this command proves its server-observed handshake before approving it.",
467
+ };
468
+ }
307
469
  return {
308
470
  status: "ok",
309
471
  options: {
@@ -319,9 +481,36 @@ export function parseInstallBridgeConductorArgs(argv) {
319
481
  branches,
320
482
  skipWorkflow,
321
483
  executorId,
484
+ ...(epicRunIds.length > 0 ? { epicRunIds } : {}),
485
+ ...(repoWide ? { repoWide: true } : {}),
486
+ ...(launchProfile ? { launchProfile } : {}),
487
+ ...(baseUrl ? { baseUrl } : {}),
488
+ ...(launchProfile
489
+ ? { executionMode: executionMode ?? (executorId !== undefined ? "durable" : "local") }
490
+ : {}),
491
+ ...(policyFile ? { policyFile } : {}),
322
492
  },
323
493
  };
324
494
  }
495
+ /** Real host-local implementations of the launch-profile seams. */
496
+ export function createDefaultLaunchProfileDeps(env) {
497
+ const store = createNodeEpicLaunchStoreDeps(env);
498
+ return {
499
+ hostname: () => os.hostname(),
500
+ nowIso: () => new Date().toISOString(),
501
+ readProfile: (name) => readEpicLaunchProfile(name, store),
502
+ writeProfile: (profile) => writeEpicLaunchProfile(profile, store),
503
+ readCredentialBinding: (repoName) => readStoredBinding(`bapi:${repoName}`, {
504
+ env,
505
+ homedir: store.homedir,
506
+ platform: store.platform,
507
+ readFile: store.readFile,
508
+ stat: (p) => statAsync(p),
509
+ }),
510
+ bindCredential: (repoName, baseUrl) => bindEpicLaunchCredential(repoName, baseUrl, store),
511
+ readPolicyFile: (absolutePath) => readFileAsync(absolutePath, "utf-8"),
512
+ };
513
+ }
325
514
  // ---------------------------------------------------------------------------
326
515
  // Bounded readiness observation (BAPI-779)
327
516
  // ---------------------------------------------------------------------------
@@ -435,7 +624,12 @@ function buildExecutorProvisioningPlans(executor, executorId, baseUrl,
435
624
  * access rather than from a flag, so the unit can only ever serve the
436
625
  * repository this install actually authenticated for.
437
626
  */
438
- repos) {
627
+ repos,
628
+ /**
629
+ * The claim scope the unit starts with (BAPI-1137). Exactly one side is set,
630
+ * validated at parse time — a unit without a scope fails on every boot.
631
+ */
632
+ claimScope) {
439
633
  if (executor.platform !== "darwin" && executor.platform !== "linux") {
440
634
  return {
441
635
  ok: false,
@@ -455,6 +649,13 @@ repos) {
455
649
  baseUrl,
456
650
  homeDir: executor.homeDir,
457
651
  envPath: executor.envPath,
652
+ // Forwarded so the supervised process starts with the scope the
653
+ // operator chose. Without it the executor refuses to start (R54) and the
654
+ // service manager restarts it forever.
655
+ ...(claimScope.epicRunIds !== undefined && claimScope.epicRunIds.length > 0
656
+ ? { epicRunIds: claimScope.epicRunIds }
657
+ : {}),
658
+ ...(claimScope.repoWide === true ? { repoWide: true } : {}),
458
659
  }, platform);
459
660
  }
460
661
  catch (err) {
@@ -523,23 +724,102 @@ async function runExecutorProvisioningPhase(deps, executor, plans, access) {
523
724
  return null;
524
725
  }
525
726
  deps.log(" the service manager accepted the unit.");
727
+ // ---- Read-only local inspection (BAPI-1137) ---------------------------
728
+ // Reported as its own fact, deliberately BEFORE and separately from the server
729
+ // observation below. "The manager accepted the unit" and "the manager is
730
+ // running it" and "Bridge has seen it claim" are three different statements,
731
+ // and an operator who reads one as another is exactly how a dead durable host
732
+ // gets called healthy.
733
+ let localState = "unknown";
734
+ if (executor.inspectServiceState) {
735
+ try {
736
+ const inspection = await executor.inspectServiceState(plans.install);
737
+ localState = inspection.state;
738
+ }
739
+ catch {
740
+ // A probe that throws leaves the state unknown. Nothing here is allowed to
741
+ // upgrade "I could not ask" into a state.
742
+ localState = "unknown";
743
+ }
744
+ }
745
+ deps.log(` local service state (read-only): ${localState}`);
746
+ deps.log(" that is a LOCAL fact only — it says nothing about whether Bridge has observed a claim.");
747
+ if (executor.inspectDurability) {
748
+ let durability = null;
749
+ try {
750
+ durability = await executor.inspectDurability(plans.install);
751
+ }
752
+ catch {
753
+ durability = null;
754
+ }
755
+ if (durability === null || durability.prerequisite === "unverifiable") {
756
+ deps.log(" durable session: unverifiable — this run could not establish whether the executor " +
757
+ "survives logout.");
758
+ }
759
+ else if (durability.prerequisite === "configured") {
760
+ deps.log(" durable session: configured — the executor survives logout on this host.");
761
+ }
762
+ else if (durability.prerequisite === "session-bound") {
763
+ // The probe answered, and what it answered is "a login session exists" —
764
+ // not "this survives logout". Reported as the qualified fact it is, with
765
+ // the session prerequisite the operator has to keep true.
766
+ deps.log(" durable session: session-bound — a login session exists, but the executor is NOT " +
767
+ "logout-durable on this host.");
768
+ if (durability.remediation) {
769
+ deps.log(` manual action: ${durability.remediation}`);
770
+ }
771
+ }
772
+ else if (durability.prerequisite === "not-configured") {
773
+ // NOT durably configured, said plainly, with the command the OPERATOR runs.
774
+ // Bridge never runs it: enabling lingering changes how the host behaves
775
+ // after logout, and that is the operator's decision on their own machine.
776
+ deps.log(" durable session: NOT durably configured — the executor stops when you log out.");
777
+ if (durability.remediation) {
778
+ deps.log(` manual action: ${durability.remediation}`);
779
+ }
780
+ }
781
+ else {
782
+ // `not-applicable`. Unreachable in practice — this phase only runs on
783
+ // darwin and linux, both of which have a prerequisite — but stated rather
784
+ // than left as silence, because a fact that renders nothing is
785
+ // indistinguishable from a fact that was never collected.
786
+ deps.log(" durable session: not applicable on this platform.");
787
+ }
788
+ }
526
789
  // ---- Bounded observation ----------------------------------------------
527
790
  // Only reached after a SUCCESSFUL start: polling for an executor that was
528
791
  // never started would burn the whole budget to learn what we already know.
529
792
  deps.log(` watching the server for an executor claim (up to ${Math.round(EXECUTOR_OBSERVATION_BUDGET_MS / 1000)}s)…`);
530
793
  const observation = await observeExecutorReadiness(access, deps.fetchJson, executor);
531
794
  if (observation.status === "observed") {
532
- deps.log(" the server observed this executor.");
795
+ deps.log(` the server observed an executor claim for this repository (requested executor id: ${plans.install.executorId}).`);
796
+ // BAPI-1137, honestly scoped: the readiness contract this client can read is
797
+ // REPOSITORY-scoped — `GET .../conductor-readiness?repo_name=` returns
798
+ // `executor.{liveness_readable,last_seen_at,last_seen_age_seconds,ready}`
799
+ // and carries no executor id, because the server derives liveness from the
800
+ // repository's `executor_jobs` rows. So this observation proves an executor
801
+ // is claiming for this repository; it cannot prove it is THIS executor id.
802
+ // The honest move is to say so rather than to invent a request or response
803
+ // field the endpoint does not implement — see the durable-host runbook's
804
+ // prerequisite note for the server-side contract this needs.
805
+ deps.log(" note: the readiness contract is repository-scoped and carries no executor id, so this");
806
+ deps.log(" confirms an executor is claiming for this repository — not specifically the one just started.");
807
+ deps.log(" if another executor already serves this repository, verify the requested id yourself before");
808
+ deps.log(" treating this host as the one doing the work.");
533
809
  return observation.readiness;
534
810
  }
535
811
  // The honest degradation: name what WAS achieved and what was not, separately.
536
- deps.log(" the unit was written and the service manager started it,");
537
- deps.log(" but the server did not observe an executor claim within the budget " +
538
- `(${observation.attempts} read(s), ${observation.readFailures} unreadable).`);
812
+ deps.log(` started locally, not observed (executor id: ${plans.install.executorId}).`);
813
+ deps.log(` local service state (read-only): ${localState}`);
814
+ deps.log(" the unit was written and the service manager started it, but the server did not observe " +
815
+ `an executor claim within the budget (${observation.attempts} read(s), ` +
816
+ `${observation.readFailures} unreadable).`);
539
817
  deps.log(observation.lastReady === false
540
818
  ? " the server last reported this repository's executor as stale/not ready."
541
819
  : " the server has not yet seen any executor claim for this repository.");
542
820
  deps.log(" executor_ready is NOT claimed — only a live server observation can produce it.");
821
+ deps.log(" escalate to the host's operating party (operator-operated) or to Bridge operations " +
822
+ "(Bridge-operated) with the repository name and the executor id above.");
543
823
  return null;
544
824
  }
545
825
  // ---------------------------------------------------------------------------
@@ -755,6 +1035,265 @@ async function runConductorLocalObservabilityPhase(deps, local) {
755
1035
  /**
756
1036
  * Run the nested conductor installer. Returns the process exit code.
757
1037
  */
1038
+ // ---------------------------------------------------------------------------
1039
+ // Epic launch profile phase (BAPI-1260)
1040
+ // ---------------------------------------------------------------------------
1041
+ /** The packaged CLI's own npm spec, used when no absolute build path resolves. */
1042
+ const LAUNCH_NPM_CHANNEL_SPEC = `${MCP_PACKAGE_NAME}@latest`;
1043
+ /**
1044
+ * The stable invocation a profile records. A durable profile requires the
1045
+ * absolute packaged path (the same resolver the service unit uses, which already
1046
+ * refuses npx caches); a local one may fall back to the published package on
1047
+ * a named channel. Never a transient `_npx` cache path.
1048
+ */
1049
+ function resolveLaunchRuntime(executor, mode) {
1050
+ const invocation = executor?.resolveInvocation();
1051
+ if (invocation?.ok) {
1052
+ const entryPath = invocation.invocation.prefixArgs[0];
1053
+ if (typeof entryPath === "string" && entryPath.length > 0) {
1054
+ return {
1055
+ ok: true,
1056
+ runtime: { kind: "packaged", executable: invocation.invocation.executable, entryPath },
1057
+ };
1058
+ }
1059
+ }
1060
+ if (mode === "durable") {
1061
+ return {
1062
+ ok: false,
1063
+ error: invocation && !invocation.ok
1064
+ ? invocation.error
1065
+ : "no absolute packaged CLI path could be resolved for a durable launch.",
1066
+ };
1067
+ }
1068
+ return { ok: true, runtime: { kind: "npm-channel", spec: LAUNCH_NPM_CHANNEL_SPEC } };
1069
+ }
1070
+ /** Loopback / link-local / private hostnames the API server cannot reach from elsewhere. */
1071
+ function isHostLocalHostname(hostname) {
1072
+ const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
1073
+ return (h === "localhost" ||
1074
+ h.endsWith(".localhost") ||
1075
+ h === "::1" ||
1076
+ /^127\./.test(h) ||
1077
+ /^0\.0\.0\.0$/.test(h) ||
1078
+ /^169\.254\./.test(h));
1079
+ }
1080
+ /**
1081
+ * Assess the policy's notification destination from the API SERVER's network
1082
+ * perspective. Presence and reachability SHAPE only — the authoritative
1083
+ * delivery check is the server's own `verify_notify_webhook` at approval, and a
1084
+ * failure there holds activation. A launcher-local listener can never pass
1085
+ * here for a remote target: the server cannot reach this machine's loopback.
1086
+ */
1087
+ function assessNotification(policy, attended, targetBaseUrl) {
1088
+ if (attended) {
1089
+ return { status: "not-applicable", detail: "the referenced policy is attended; no escalation webhook is required." };
1090
+ }
1091
+ const notify = policy && typeof policy.notify === "object" && policy.notify !== null
1092
+ ? policy.notify
1093
+ : null;
1094
+ const url = typeof notify?.webhook_url === "string" ? notify.webhook_url.trim() : "";
1095
+ if (url === "") {
1096
+ return {
1097
+ status: "missing",
1098
+ detail: "an unattended launch needs notify.webhook_url in its --policy-file" +
1099
+ (notify && "local_sink" in notify ? " (notify.local_sink is not an escalation channel)." : "."),
1100
+ };
1101
+ }
1102
+ let webhook;
1103
+ let target;
1104
+ try {
1105
+ webhook = new URL(url);
1106
+ target = new URL(targetBaseUrl);
1107
+ }
1108
+ catch {
1109
+ return { status: "missing", detail: "notify.webhook_url is not an absolute URL." };
1110
+ }
1111
+ if (webhook.protocol !== "https:" && webhook.protocol !== "http:") {
1112
+ return { status: "missing", detail: "notify.webhook_url must be an http(s) URL." };
1113
+ }
1114
+ if (isHostLocalHostname(webhook.hostname) && !isHostLocalHostname(target.hostname)) {
1115
+ return {
1116
+ status: "missing",
1117
+ detail: "notify.webhook_url points at this machine's loopback, which the API server at the selected " +
1118
+ "target cannot reach. Use an API-reachable, durable notification service — a terminal-held " +
1119
+ "local listener cannot establish server reachability.",
1120
+ };
1121
+ }
1122
+ return {
1123
+ status: "ok",
1124
+ detail: "notify.webhook_url is declared and addressable from the selected API server; the server " +
1125
+ "delivers a verification to it at approval, and a failure there holds activation.",
1126
+ };
1127
+ }
1128
+ /**
1129
+ * Verify every mandatory launch prerequisite at the selected target, then —
1130
+ * and only then — persist the profile and print the one command that selects
1131
+ * it. Any unproved prerequisite yields `incomplete-setup` with the reason, and
1132
+ * no profile is written, so a profile's existence means setup was sufficient.
1133
+ *
1134
+ * Consent is READ from the server (`unattended.conductor_allowed`), never
1135
+ * written: ordinary configuration writes cannot self-grant unattended
1136
+ * authority, and nothing in this phase tries to.
1137
+ */
1138
+ async function runLaunchProfilePhase(deps, options, inputs) {
1139
+ const name = options.launchProfile;
1140
+ const mode = options.executionMode ?? "local";
1141
+ const lp = deps.launchProfiles ?? createDefaultLaunchProfileDeps(deps.env);
1142
+ const repoName = inputs.access.repoName;
1143
+ const gaps = [];
1144
+ deps.log("");
1145
+ deps.log(`Epic launch profile '${name}' — verification…`);
1146
+ deps.log(` target: ${inputs.baseUrl}`);
1147
+ deps.log(` repository: ${repoName}`);
1148
+ deps.log(` mode: ${mode}`);
1149
+ // (1) Access at THIS target — only a server answer proves the key works there.
1150
+ if (inputs.readiness === null) {
1151
+ gaps.push("the selected target's conductor readiness could not be read with this repository's credential.");
1152
+ }
1153
+ // (2) The policy reference and what it implies.
1154
+ let policy = null;
1155
+ let policyPath = null;
1156
+ if (options.policyFile !== undefined) {
1157
+ policyPath = path.resolve(deps.cwd, options.policyFile);
1158
+ try {
1159
+ const parsed = JSON.parse(await lp.readPolicyFile(policyPath));
1160
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
1161
+ policy = parsed;
1162
+ }
1163
+ else {
1164
+ gaps.push("the --policy-file does not hold a JSON object.");
1165
+ }
1166
+ }
1167
+ catch {
1168
+ gaps.push("the --policy-file could not be read as JSON.");
1169
+ }
1170
+ }
1171
+ const attended = policy?.posture === "attended";
1172
+ // (3) Consent — the server's administrative authority, read not written.
1173
+ const consent = inputs.readiness?.unattended?.conductor_allowed;
1174
+ if (attended) {
1175
+ deps.log(" consent: not applicable (attended policy)");
1176
+ }
1177
+ else if (consent === true) {
1178
+ deps.log(" consent: confirmed by the server (unattended_epic_implementer_allowed)");
1179
+ }
1180
+ else {
1181
+ deps.log(` consent: ${consent === false ? "NOT granted" : "could not be verified"}`);
1182
+ gaps.push(consent === false
1183
+ ? "unattended consent is not granted for this repository; an administrator sets it " +
1184
+ "(docs/claude/account-settings-operator-runbook.md) — this command never writes it."
1185
+ : "unattended consent could not be verified at the selected target (older server or unreadable report).");
1186
+ }
1187
+ // (4) Notification, from the server's network perspective.
1188
+ const notification = assessNotification(policy, attended, inputs.baseUrl);
1189
+ deps.log(` notify: ${notification.status} — ${notification.detail}`);
1190
+ if (notification.status === "missing")
1191
+ gaps.push(notification.detail);
1192
+ // (5) The actor that consumes the work.
1193
+ if (mode === "durable") {
1194
+ deps.log(` actor: ${inputs.actorObserved ? "server-observed executor handshake" : "NOT observed"}`);
1195
+ if (!inputs.actorObserved) {
1196
+ gaps.push("no executor was observed by the server for this repository; a written unit, a started " +
1197
+ "service, or a local process is not proof. Provision it with --executor-id and let the " +
1198
+ "bounded observation complete.");
1199
+ }
1200
+ }
1201
+ else {
1202
+ deps.log(" actor: local — drive-epic starts this repository's plane and scoped lanes itself");
1203
+ }
1204
+ // (6) A stable runtime invocation.
1205
+ if (!inputs.runtime.ok) {
1206
+ gaps.push(`the packaged CLI invocation is not stable: ${inputs.runtime.error}`);
1207
+ }
1208
+ else {
1209
+ deps.log(` runtime: ${inputs.runtime.runtime.kind === "packaged" ? "packaged absolute path" : inputs.runtime.runtime.spec}`);
1210
+ }
1211
+ // (7) The existing profile: a changed binding is re-approved explicitly.
1212
+ const existing = await lp.readProfile(name);
1213
+ if (existing.ok) {
1214
+ const prior = existing.profile;
1215
+ const changed = prior.baseUrl !== inputs.baseUrl || prior.repoName !== repoName || prior.executionMode !== mode;
1216
+ if (changed) {
1217
+ deps.log("");
1218
+ deps.log(` profile '${name}' exists with a DIFFERENT binding:`);
1219
+ deps.log(` current: ${prior.baseUrl} repo=${prior.repoName} mode=${prior.executionMode}`);
1220
+ deps.log(` proposed: ${inputs.baseUrl} repo=${repoName} mode=${mode}`);
1221
+ const rebind = await confirm(deps, `Re-bind profile '${name}' to the proposed target? [y/N]: `);
1222
+ if (!rebind)
1223
+ gaps.push(`re-binding profile '${name}' was declined; it was left unchanged.`);
1224
+ }
1225
+ }
1226
+ else if (existing.kind !== "profile-missing") {
1227
+ // Unreadable or from a newer/older schema: reported, and replaced only
1228
+ // through the same consented write below.
1229
+ deps.log(` existing profile: ${existing.message}`);
1230
+ }
1231
+ // (8) The credential binding — the explicit one-time upgrade path.
1232
+ if (gaps.length === 0) {
1233
+ const bound = await lp.readCredentialBinding(repoName);
1234
+ if (bound === inputs.baseUrl) {
1235
+ deps.log(` credential: bapi:${repoName} is already approved for this target`);
1236
+ }
1237
+ else {
1238
+ deps.log(bound === null
1239
+ ? ` credential: bapi:${repoName} is not yet approved for any endpoint (legacy repo-only binding)`
1240
+ : ` credential: bapi:${repoName} is approved for a DIFFERENT endpoint`);
1241
+ const approve = await confirm(deps, `Approve the stored credential bapi:${repoName} for ${inputs.baseUrl} only? [y/N]: `);
1242
+ if (!approve) {
1243
+ gaps.push("the credential binding was not approved, so a launch could not use the stored key.");
1244
+ }
1245
+ else {
1246
+ const result = await lp.bindCredential(repoName, inputs.baseUrl);
1247
+ if (!result.ok)
1248
+ gaps.push(`the credential binding could not be written: ${result.error}`);
1249
+ else
1250
+ deps.log(` credential: ${result.action} — approved for this target only`);
1251
+ }
1252
+ }
1253
+ }
1254
+ if (gaps.length > 0 || !inputs.runtime.ok) {
1255
+ deps.log("");
1256
+ deps.log(` incomplete — profile '${name}' was NOT written:`);
1257
+ for (const gap of gaps)
1258
+ deps.log(` → ${gap}`);
1259
+ deps.log(` re-run: npx -y ${MCP_PACKAGE_NAME} install conductor --launch-profile ${name} ` +
1260
+ `--base-url ${inputs.baseUrl} --required-check <name> ...`);
1261
+ return { complete: false };
1262
+ }
1263
+ const write = await confirm(deps, `Write epic launch profile '${name}' (references only, no secrets)? [y/N]: `);
1264
+ if (!write) {
1265
+ deps.log(" declined — no profile was written.");
1266
+ return { complete: false };
1267
+ }
1268
+ const profile = {
1269
+ version: EPIC_LAUNCH_PROFILE_VERSION,
1270
+ name,
1271
+ baseUrl: inputs.baseUrl,
1272
+ repoName,
1273
+ executionMode: mode,
1274
+ host: lp.hostname(),
1275
+ runtime: inputs.runtime.runtime,
1276
+ policyRef: policyPath !== null ? { kind: "file", path: policyPath } : null,
1277
+ credentialRef: { store: "bridge-credentials", target: `bapi:${repoName}` },
1278
+ approvedAt: lp.nowIso(),
1279
+ };
1280
+ const written = await lp.writeProfile(profile);
1281
+ if (!written.ok) {
1282
+ deps.errorLog(` profile write failed: ${written.message}`);
1283
+ return { complete: false };
1284
+ }
1285
+ deps.log(` ${written.action} ${written.path}`);
1286
+ deps.log("");
1287
+ deps.log("Launch command (fresh shell; no exports needed):");
1288
+ deps.log(` ${renderEpicLaunchCommand(profile, {
1289
+ epicKey: options.epicKey ?? "<EPIC>",
1290
+ planFile: options.planFile ? path.resolve(deps.cwd, options.planFile) : "<path>",
1291
+ })
1292
+ // Placeholders are for the operator to replace, not literal arguments.
1293
+ .replace("'<EPIC>'", "<EPIC>")
1294
+ .replace("'<path>'", "<path>")}`);
1295
+ return { complete: true };
1296
+ }
758
1297
  export async function runInstallBridgeConductorCli(argv, deps) {
759
1298
  const parsed = parseInstallBridgeConductorArgs(argv);
760
1299
  if (parsed.status === "help") {
@@ -769,12 +1308,31 @@ export async function runInstallBridgeConductorCli(argv, deps) {
769
1308
  }
770
1309
  const options = parsed.options;
771
1310
  // ---- Base URL: normalized ONCE, confirmed BEFORE any network call ------
772
- const baseUrlResult = deps.resolveBaseUrl(deps.env);
773
- if (!baseUrlResult.ok) {
774
- deps.errorLog(baseUrlResult.error);
775
- return 1;
1311
+ // BAPI-1260: an epic launch profile has NO production default. Its target is
1312
+ // --base-url or BAPI_BASE_URL (they must agree), decided here before any
1313
+ // request; the hosted default below applies to the legacy flow only.
1314
+ let baseUrl;
1315
+ if (options.launchProfile !== undefined) {
1316
+ const target = resolveEpicLaunchTarget({
1317
+ flagBaseUrl: options.baseUrl,
1318
+ profile: null,
1319
+ env: deps.env,
1320
+ });
1321
+ if (!target.ok) {
1322
+ deps.errorLog(target.message);
1323
+ deps.errorLog("Nothing was sent anywhere and nothing was written.");
1324
+ return 1;
1325
+ }
1326
+ baseUrl = target.baseUrl;
1327
+ }
1328
+ else {
1329
+ const baseUrlResult = deps.resolveBaseUrl(deps.env);
1330
+ if (!baseUrlResult.ok) {
1331
+ deps.errorLog(baseUrlResult.error);
1332
+ return 1;
1333
+ }
1334
+ baseUrl = baseUrlResult.value;
776
1335
  }
777
- const baseUrl = baseUrlResult.value;
778
1336
  // ---- Consent #1: the resolved base URL ---------------------------------
779
1337
  // This consent gates the FIRST network request of any kind — including the
780
1338
  // read-only doctor readiness GET (which carries this repository's API key)
@@ -782,6 +1340,10 @@ export async function runInstallBridgeConductorCli(argv, deps) {
782
1340
  // BAPI_BASE_URL is unset, so the operator must see and confirm the target
783
1341
  // before the key is sent anywhere. No TTY means no consent means abort.
784
1342
  deps.log(`Bridge API base URL: ${baseUrl}`);
1343
+ if (options.launchProfile !== undefined) {
1344
+ deps.log(` explicit target for epic launch profile '${options.launchProfile}'` +
1345
+ (baseUrl === DEFAULT_BAPI_BASE_URL ? " — this is the PRODUCTION Bridge API, selected deliberately." : "."));
1346
+ }
785
1347
  const baseUrlOk = await confirm(deps, "Bootstrap against this URL? [y/N]: ");
786
1348
  if (!baseUrlOk) {
787
1349
  deps.log("Aborted — no network call (readiness GET or bootstrap POST) was attempted.");
@@ -847,7 +1409,7 @@ export async function runInstallBridgeConductorCli(argv, deps) {
847
1409
  // could not be resolved the doctor above has already produced a fatal
848
1410
  // section, so this fallback only matters for the narrow window where a
849
1411
  // caller injected a non-fatal doctor.
850
- const planned = buildExecutorProvisioningPlans(executor, options.executorId, baseUrl, access ? [access.repoName] : []);
1412
+ const planned = buildExecutorProvisioningPlans(executor, options.executorId, baseUrl, access ? [access.repoName] : [], { epicRunIds: options.epicRunIds, repoWide: options.repoWide });
851
1413
  if (!planned.ok) {
852
1414
  deps.errorLog("");
853
1415
  deps.errorLog(`Executor provisioning refused — ${planned.error}`);
@@ -908,6 +1470,11 @@ export async function runInstallBridgeConductorCli(argv, deps) {
908
1470
  else if (executor) {
909
1471
  deps.log(" Executor provisioning: skipped (no --executor-id supplied)");
910
1472
  }
1473
+ if (options.launchProfile !== undefined) {
1474
+ deps.log(` Epic launch profile '${options.launchProfile}': would verify target access, credential ` +
1475
+ "binding, consent, notification, and actor, and persist the profile only if all hold " +
1476
+ "(each write behind its own consent) — nothing was read from or written to the profile store.");
1477
+ }
911
1478
  renderCapabilityMatrix(deps, firstReport.readiness);
912
1479
  deps.log("");
913
1480
  deps.log("complete-with-manual-steps (dry run)");
@@ -1127,6 +1694,24 @@ export async function runInstallBridgeConductorCli(argv, deps) {
1127
1694
  for (const action of manualActions)
1128
1695
  deps.log(` → ${action}`);
1129
1696
  }
1697
+ // ---- BAPI-1260: the reusable epic launch profile ------------------------
1698
+ // Last, because it can only be approved on facts every earlier phase
1699
+ // produced: server-observed readiness at THIS target, the executor handshake
1700
+ // for a durable host, and the operator's consents.
1701
+ if (options.launchProfile !== undefined) {
1702
+ const launch = await runLaunchProfilePhase(deps, options, {
1703
+ access,
1704
+ baseUrl,
1705
+ readiness: postReadiness,
1706
+ actorObserved: observedReadiness !== null || postReadiness?.executor.ready === true,
1707
+ runtime: resolveLaunchRuntime(executor, options.executionMode ?? "local"),
1708
+ });
1709
+ deps.log("");
1710
+ if (!launch.complete) {
1711
+ deps.log("incomplete-setup");
1712
+ return 1;
1713
+ }
1714
+ }
1130
1715
  deps.log("");
1131
1716
  // The executor leg can only be true when the server observed a recent claim,
1132
1717
  // so a bootstrap alone can never print `complete`. The two local phases above