@bridge_gpt/mcp-server 0.2.62 → 0.2.66

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 (113) hide show
  1. package/README.md +66 -15
  2. package/build/agent-launchers/claude-executor-adapter.js +145 -20
  3. package/build/agent-launchers/executor-adapter.js +81 -2
  4. package/build/agents.generated.js +10 -1
  5. package/build/bounded-wait.js +32 -1
  6. package/build/bridge-config.js +104 -0
  7. package/build/codex-bridge-profile.js +10 -1
  8. package/build/commands.generated.js +7 -6
  9. package/build/conductor/bridge-api-client.js +565 -32
  10. package/build/conductor/bring-up-facts.js +3 -1
  11. package/build/conductor/doctor.js +23 -14
  12. package/build/conductor/epic-child-continuation.js +351 -0
  13. package/build/conductor/epic-implementer-cli.js +154 -17
  14. package/build/conductor/git-hooks.js +529 -118
  15. package/build/conductor/guided-repair.js +323 -0
  16. package/build/conductor/hook-runtime-qualification.js +340 -0
  17. package/build/conductor/readiness-cli.js +30 -9
  18. package/build/conductor/readiness-sections.js +5 -4
  19. package/build/conductor/readiness.js +45 -12
  20. package/build/conductor/recovery-cli.js +859 -27
  21. package/build/conductor/recovery-operations.js +574 -5
  22. package/build/conductor/repair-contract.js +69 -3
  23. package/build/conductor-bundle-artifacts.js +1 -0
  24. package/build/credential-store.js +52 -13
  25. package/build/diagnostic-text.js +36 -0
  26. package/build/docs.generated.js +3 -2
  27. package/build/doctor.js +147 -21
  28. package/build/drive-epic-key.js +12 -0
  29. package/build/drive-epic.js +1537 -125
  30. package/build/epic-coherence-input-cli.js +137 -0
  31. package/build/epic-coherence-input.js +920 -0
  32. package/build/epic-coherence-input.test-fixtures.js +175 -0
  33. package/build/epic-implementer-bin.js +41 -33
  34. package/build/epic-implementer-bundle-cli.js +14 -1
  35. package/build/epic-launch-diagnostic.js +31 -0
  36. package/build/epic-launch-profile.js +58 -19
  37. package/build/epic-run-status.js +5 -0
  38. package/build/epic-shared/bridge-client.js +46 -4
  39. package/build/executor/cli.js +39 -0
  40. package/build/executor/completion-contract.js +423 -0
  41. package/build/executor/completion-supervision.js +291 -0
  42. package/build/executor/correction-dispatch.js +60 -0
  43. package/build/executor/credentials.js +3 -1
  44. package/build/executor/deps.js +65 -1
  45. package/build/executor/env.js +12 -0
  46. package/build/executor/heartbeat.js +13 -3
  47. package/build/executor/http-client.js +91 -4
  48. package/build/executor/integration-job.js +6 -1
  49. package/build/executor/job-errors.js +61 -4
  50. package/build/executor/job-log-registry.js +340 -73
  51. package/build/executor/job-runner.js +1550 -55
  52. package/build/executor/job-types.js +9 -6
  53. package/build/executor/preflight.js +189 -1
  54. package/build/executor/process-containment.js +756 -0
  55. package/build/executor/process.js +235 -5
  56. package/build/executor/prompt-spec.js +366 -1
  57. package/build/executor/provider-checks.js +69 -0
  58. package/build/executor/rebase-finalization.js +351 -0
  59. package/build/executor/reconcile-overlap-job.js +77 -84
  60. package/build/executor/required-verification.js +313 -0
  61. package/build/executor/required-work-ledger.js +1121 -0
  62. package/build/executor/results.js +142 -0
  63. package/build/executor/resume-pre-spawn.js +87 -4
  64. package/build/executor/runner.js +168 -0
  65. package/build/executor/stale-artifacts.js +5 -4
  66. package/build/executor/test-declaration.js +20 -0
  67. package/build/executor/types.js +13 -0
  68. package/build/executor/watch-cli.js +35 -8
  69. package/build/executor/worker-command.js +70 -34
  70. package/build/executor/worker-finalization.js +168 -26
  71. package/build/executor/worker-guard-hook.js +75 -14
  72. package/build/executor/worker-log.js +137 -70
  73. package/build/executor/worktree-gc.js +30 -6
  74. package/build/executor/worktree.js +13 -0
  75. package/build/executor-launch.js +658 -0
  76. package/build/index.js +594 -1152
  77. package/build/init.js +107 -4
  78. package/build/install-bridge-conductor.js +231 -41
  79. package/build/install-bridge.js +284 -20
  80. package/build/install-doctor.js +172 -30
  81. package/build/install-reexec.js +69 -7
  82. package/build/macos-terminal-permission-guidance.js +16 -0
  83. package/build/merge-pull-request.js +1 -1
  84. package/build/model-thresholds-tool.js +185 -0
  85. package/build/pipelines.generated.js +9 -8
  86. package/build/plan-discovery.js +137 -0
  87. package/build/plan-epic-implementer-eligibility.js +2 -2
  88. package/build/plane/cli.js +37 -3
  89. package/build/plane/member-logs.js +3 -0
  90. package/build/plane/member-roster.js +7 -1
  91. package/build/plane/shutdown.js +59 -1
  92. package/build/plane/status.js +4 -0
  93. package/build/plane/supervisor.js +146 -0
  94. package/build/plane/worker-exit-code.js +2 -0
  95. package/build/project-root.js +99 -0
  96. package/build/readme.generated.js +1 -1
  97. package/build/review-tickets.js +156 -13
  98. package/build/scripts/executor-protocol-contract-driver.js +13 -3
  99. package/build/setup-epic.js +208 -125
  100. package/build/sfcc/setup-status.js +2 -0
  101. package/build/sfcc/tool-wrapper.js +2 -0
  102. package/build/sfcc/tools-configured.js +96 -0
  103. package/build/start-tickets.js +163 -58
  104. package/build/upgrade-cli.js +109 -24
  105. package/build/version.generated.js +3 -3
  106. package/build/windows-npm-entry.js +103 -0
  107. package/build/worker-guard-hook-bin.js +5 -4
  108. package/build/worktree-core.js +6 -5
  109. package/docs/CONDUCTOR.md +116 -2
  110. package/docs/install/mcp-tool-integrations.md +1 -0
  111. package/docs/install/sfcc-integration.md +12 -7
  112. package/package.json +2 -1
  113. package/pipelines/implement-ticket.json +1 -1
package/build/doctor.js CHANGED
@@ -24,8 +24,9 @@ import { spawn } from "child_process";
24
24
  import os from "os";
25
25
  import path from "path";
26
26
  import { createDefaultStartTicketsDeps } from "./start-tickets.js";
27
+ import { defaultNpmEntryFileExists, resolveNpmEntry } from "./windows-npm-entry.js";
27
28
  import { VERSION, BUILD_COMMIT } from "./version.generated.js";
28
- import { collectInstallStatusChecks, formatInstallStatusReport, formatInstallStatusFallbackReport, resolveInstallDoctorTarget, } from "./install-doctor.js";
29
+ import { collectInstallStatusChecks, collectProjectIdentityAndRoot, formatInstallStatusReport, formatInstallStatusFallbackReport, formatProjectIdentityAndRootFallbackReport, formatProjectIdentityAndRootReport, resolveInstallDoctorTarget, } from "./install-doctor.js";
29
30
  import { codexHome, inspectBridgeCodexProfile } from "./codex-bridge-profile.js";
30
31
  import { resolveRoutingHost } from "./model-routing-host.js";
31
32
  import { modelRoutingPreferencesPath, readModelRoutingPreference } from "./model-routing-preferences.js";
@@ -34,9 +35,10 @@ import { resolveExecutorAgentAdapter } from "./agent-launchers/executor-adapter-
34
35
  import { parseDefaultOnEnvFlag } from "./env-flags.js";
35
36
  import { createBridgeApiUrls } from "./bridge-api-urls.js";
36
37
  import { probeToolSurface } from "./tool-surface-gating.js";
37
- import { readStoredBinding, resolveBapiCredentials } from "./credential-store.js";
38
- import { listEpicLaunchProfiles } from "./epic-launch-profile.js";
39
- import { resolveConductorBridgeApiAccess } from "./conductor/bridge-api-client.js";
38
+ import { readStoredBindingState, resolveBapiCredentials } from "./credential-store.js";
39
+ import { EPIC_LAUNCH_PROFILE_NAME_PATTERN, listEpicLaunchProfiles } from "./epic-launch-profile.js";
40
+ import { CONDUCTOR_DEFAULT_BASE_URL, resolveConductorBridgeApiAccess } from "./conductor/bridge-api-client.js";
41
+ import { validateHttpBaseUrl } from "./base-url.js";
40
42
  import { getLiveRepositoryConductors, getParseDispatcherHealth, } from "./epic-shared/bridge-client.js";
41
43
  import { resolveRequiredStartTicketsRepoName } from "./start-tickets-repo.js";
42
44
  import { collectWorkerContainmentDiagnostic, formatWorkerContainmentDiagnosticReport, } from "./worker-containment-diagnostic.js";
@@ -87,6 +89,12 @@ export function getDoctorUsage() {
87
89
  "bootstrap-field completeness, integration credentials, and repository-indexing",
88
90
  "state. It performs read-only GETs only and never affects the exit code.",
89
91
  "",
92
+ "Next comes an advisory 'Project identity and root' section (BAPI-1318): whether",
93
+ "each BAPI_PROJECT_ROOT (from the environment and the project MCP configs) names",
94
+ "a real project folder the server will use, and whether .bridge/config's",
95
+ "repo_name matches each project MCP config's BAPI_REPO_NAME. It only reads",
96
+ "files and never affects the exit code.",
97
+ "",
90
98
  "After that come the start-tickets prerequisite checks (for the current OS):",
91
99
  "the start-tickets preflight prerequisites plus",
92
100
  "uv, the selected agent's command, Bridge API credential resolution, and",
@@ -540,10 +548,25 @@ export function parseLauncherPin(args) {
540
548
  * spawn error, or timeout is classified `indeterminate` (never silently "warm").
541
549
  * `--no-install` guarantees the probe never downloads/warms the bucket.
542
550
  */
543
- function probeNpxNoInstallDefault(spec) {
551
+ export function probeNpxNoInstallDefault(spec, options = {}) {
552
+ const doSpawn = options.spawn ?? spawn;
544
553
  return new Promise((resolve) => {
554
+ // WINDOWS (BAPI-1319): a bare `npx` is ENOENT without a shell, so the probe
555
+ // always read "indeterminate". Map it to npm's `npx-cli.js` through Node;
556
+ // `--no-install` is kept, so doctor still can never warm the cache. Other
557
+ // platforms pass through unchanged.
558
+ const resolved = resolveNpmEntry("npx", ["--no-install", spec, "--version"], {
559
+ platform: options.platform ?? process.platform,
560
+ env: options.env ?? process.env,
561
+ execPath: options.execPath ?? process.execPath,
562
+ fileExists: options.fileExists ?? defaultNpmEntryFileExists,
563
+ });
564
+ if (resolved.status !== "resolved") {
565
+ resolve({ warmed: false, indeterminate: true });
566
+ return;
567
+ }
545
568
  try {
546
- const child = spawn("npx", ["--no-install", spec, "--version"], {
569
+ const child = doSpawn(resolved.command, resolved.args, {
547
570
  shell: false,
548
571
  stdio: "ignore",
549
572
  timeout: 30_000,
@@ -1559,6 +1582,47 @@ export async function collectOperationalAdvisories(deps) {
1559
1582
  legacyDriveHelpers,
1560
1583
  };
1561
1584
  }
1585
+ /**
1586
+ * BAPI-1299 — the "Automation queue" advisory line, from the `automation_queue`
1587
+ * block of the same health GET. Queued reviews, plans, and docs wait for the
1588
+ * background worker; this line says whether they are being drained.
1589
+ *
1590
+ * Only an `undrained` verdict WARNs. An old queue with a fresh running-job
1591
+ * heartbeat is a busy worker, reported OK with its backlog. Anything the server
1592
+ * did not positively observe — no credential, a failed read, an older server
1593
+ * with no block, an unreadable block — is SKIP, never OK. Advisory only.
1594
+ */
1595
+ export function formatAutomationQueueAdvisory(dispatcher) {
1596
+ if (dispatcher === null) {
1597
+ return " SKIP Automation queue: not checked (no Bridge credential resolved).";
1598
+ }
1599
+ const queue = dispatcher.automationQueue ?? null;
1600
+ if (queue === null) {
1601
+ return (" SKIP Automation queue: unavailable — the health read carried no queue block\n" +
1602
+ " (the read failed, or the server predates this check).");
1603
+ }
1604
+ if (queue.status === "unreadable" || queue.verdict === "unknown") {
1605
+ return (" SKIP Automation queue: unreadable — the server could not read the queue.\n" +
1606
+ " This is not evidence that the queue is stuck.");
1607
+ }
1608
+ const count = queue.queuedCount ?? 0;
1609
+ const age = queue.oldestQueuedAgeSeconds;
1610
+ const backlog = age === null ? `${count} queued` : `${count} queued, oldest waiting ${age}s`;
1611
+ if (queue.verdict === "undrained") {
1612
+ return (` WARN Automation queue: ${backlog}, and no running job shows a fresh heartbeat.\n` +
1613
+ " Queued reviews, plans, and docs wait for the background worker. Check that\n" +
1614
+ " `python worker.py` (the `worker:` dyno) is running and DISABLE_SCHEDULER is\n" +
1615
+ " not true on it. The jobs have not failed; they will run once it drains.");
1616
+ }
1617
+ if (queue.verdict === "working_backlog") {
1618
+ return (` OK Automation queue: ${backlog} — the worker is busy (running jobs are\n` +
1619
+ " heartbeating), so this is a working backlog, not a stopped worker.");
1620
+ }
1621
+ if (count === 0) {
1622
+ return " OK Automation queue: nothing queued.";
1623
+ }
1624
+ return ` OK Automation queue: ${backlog} — within the normal wait.`;
1625
+ }
1562
1626
  /** Render the operational-advisories section. Advisory: never changes the exit code. */
1563
1627
  export function formatOperationalAdvisoryReport(diagnostic) {
1564
1628
  const lines = ["", "operational advisories (advisory)"];
@@ -1577,6 +1641,7 @@ export function formatOperationalAdvisoryReport(diagnostic) {
1577
1641
  lines.push(" SKIP Parse dispatcher: unavailable — the liveness read produced no verdict.\n" +
1578
1642
  " This is not evidence that no dispatcher is running.");
1579
1643
  }
1644
+ lines.push(formatAutomationQueueAdvisory(dispatcher));
1580
1645
  if (diagnostic.conductorObservation === "unavailable") {
1581
1646
  lines.push(" SKIP Concurrent conductors: unavailable — the repository-scoped read produced\n" +
1582
1647
  " no verdict.");
@@ -1620,12 +1685,20 @@ export function formatOperationalAdvisoryReport(diagnostic) {
1620
1685
  * migrates anything.
1621
1686
  */
1622
1687
  export async function collectEpicLaunchProfileDiagnostic(deps) {
1688
+ const selectedName = deps.env.BAPI_EPIC_LAUNCH_PROFILE?.trim();
1689
+ const selected = selectedName
1690
+ ? EPIC_LAUNCH_PROFILE_NAME_PATTERN.test(selectedName)
1691
+ ? { name: selectedName, status: "missing" }
1692
+ : { name: "<invalid>", status: "invalid" }
1693
+ : undefined;
1623
1694
  const listing = await listEpicLaunchProfiles(deps);
1624
1695
  if (!listing.ok)
1625
- return { store: "unreadable", message: listing.message };
1696
+ return { store: "unreadable", message: listing.message, ...(selected ? { selected } : {}) };
1626
1697
  if (!listing.present)
1627
- return { store: "absent" };
1698
+ return { store: "absent", ...(selected ? { selected: { ...selected, status: selected.status === "invalid" ? "invalid" : "missing" } } : {}) };
1628
1699
  const host = deps.hostname();
1700
+ const production = validateHttpBaseUrl(CONDUCTOR_DEFAULT_BASE_URL);
1701
+ const productionBaseUrl = production.ok ? production.baseUrl : null;
1629
1702
  const profiles = [];
1630
1703
  for (const entry of listing.entries) {
1631
1704
  if (!entry.validation.ok) {
@@ -1633,22 +1706,32 @@ export async function collectEpicLaunchProfileDiagnostic(deps) {
1633
1706
  continue;
1634
1707
  }
1635
1708
  const p = entry.validation.profile;
1636
- const bound = await readStoredBinding(p.credentialRef.target, {
1709
+ const binding = await readStoredBindingState(p.credentialRef.target, {
1637
1710
  env: deps.env,
1638
1711
  homedir: deps.homedir,
1639
1712
  platform: process.platform,
1640
1713
  readFile: deps.readFile,
1641
- // Presence probe via read: the binding reader treats any failure as unbound.
1714
+ // Presence probe via read: an unreadable store is reported as unbound.
1642
1715
  stat: async (filePath) => {
1643
1716
  await deps.readFile(filePath);
1644
1717
  return { mode: 0o600 };
1645
1718
  },
1646
1719
  });
1647
1720
  const hostMatches = p.host === host;
1648
- const credentialBinding = bound === null ? "unbound" : bound === p.baseUrl ? "matches" : "different";
1721
+ // Only a genuinely ABSENT binding field is accepted at production; a
1722
+ // present-but-empty field is a broken binding, reported as different.
1723
+ const credentialBinding = binding.state === "bound"
1724
+ ? binding.baseUrl === p.baseUrl
1725
+ ? "matches"
1726
+ : "different"
1727
+ : binding.state === "invalid"
1728
+ ? "different"
1729
+ : binding.state === "absent" && productionBaseUrl !== null && p.baseUrl === productionBaseUrl
1730
+ ? "production-default"
1731
+ : "unbound";
1649
1732
  profiles.push({
1650
1733
  name: p.name,
1651
- usable: hostMatches && credentialBinding === "matches",
1734
+ usable: hostMatches && (credentialBinding === "matches" || credentialBinding === "production-default"),
1652
1735
  target: p.baseUrl,
1653
1736
  repo: p.repoName,
1654
1737
  mode: p.executionMode,
@@ -1656,13 +1739,33 @@ export async function collectEpicLaunchProfileDiagnostic(deps) {
1656
1739
  credentialBinding,
1657
1740
  });
1658
1741
  }
1659
- return { store: "present", profiles };
1742
+ const selectedProfile = selected?.status !== "invalid" ? profiles.find((p) => p.name === selected?.name) : undefined;
1743
+ return {
1744
+ store: "present",
1745
+ profiles,
1746
+ ...(selected ? { selected: { name: selected.name, status: selected.status === "invalid" ? "invalid" : !selectedProfile ? "missing" : selectedProfile.usable ? "usable" : "unusable" } } : {}),
1747
+ };
1660
1748
  }
1661
- /** Render the section, or `null` when this host has no profile store. */
1749
+ /**
1750
+ * BAPI-1272 — a target-selection fact, shown whether or not a profile store
1751
+ * exists. It says nothing about credentials or readiness.
1752
+ */
1753
+ export const EPIC_LAUNCH_DEFAULT_TARGET_LINE = ` [info] drive-epic with no profile, --base-url, or BAPI_BASE_URL targets production ` +
1754
+ `(${CONDUCTOR_DEFAULT_BASE_URL}); production needs no profile.`;
1755
+ /**
1756
+ * Render the section. With no profile store it holds only the default-target
1757
+ * line (BAPI-1272) — the common production case.
1758
+ */
1662
1759
  export function formatEpicLaunchProfileDiagnosticReport(diagnostic) {
1760
+ const lines = ["", "Epic launch profiles (read-only)", "", EPIC_LAUNCH_DEFAULT_TARGET_LINE];
1761
+ if (diagnostic.selected) {
1762
+ const { name, status } = diagnostic.selected;
1763
+ lines.push(` [${status === "usable" ? "ok" : "fail"}] BAPI_EPIC_LAUNCH_PROFILE selects ${name}: ${status}.`);
1764
+ if (status !== "usable")
1765
+ lines.push(" Epic launch will refuse this selection; inspect the local profile and credential binding.");
1766
+ }
1663
1767
  if (diagnostic.store === "absent")
1664
- return null;
1665
- const lines = ["", "Epic launch profiles (read-only)", ""];
1768
+ return lines.join("\n");
1666
1769
  if (diagnostic.store === "unreadable") {
1667
1770
  lines.push(` [warn] ${diagnostic.message}`);
1668
1771
  return lines.join("\n");
@@ -1684,6 +1787,9 @@ export function formatEpicLaunchProfileDiagnosticReport(diagnostic) {
1684
1787
  else if (p.credentialBinding === "different") {
1685
1788
  lines.push(" stored credential is approved for a DIFFERENT target — a launch will refuse to send it.");
1686
1789
  }
1790
+ else if (p.credentialBinding === "production-default") {
1791
+ lines.push(" stored credential is unbound, which production accepts — no binding is needed.");
1792
+ }
1687
1793
  }
1688
1794
  return lines.join("\n");
1689
1795
  }
@@ -1705,8 +1811,8 @@ export async function runDoctorCli(argv, overrides = {}) {
1705
1811
  const agent = resolveAgentSpec(parsed.options.agentName) ?? resolveAgentSpec(DEFAULT_AGENT_NAME);
1706
1812
  const collection = await collectDoctorResults(deps, parsed.options.agentName);
1707
1813
  // ---- Section order (BAPI-669, U9c) ----
1708
- // Broad header → Install status → start-tickets prerequisites → launcher cache →
1709
- // MCP tool surface. Install status renders FIRST and UNCONDITIONALLY: it answers
1814
+ // Broad header → Install status → Project identity and root (BAPI-1318) →
1815
+ // start-tickets prerequisites → launcher cache → MCP tool surface. Install status renders FIRST and UNCONDITIONALLY: it answers
1710
1816
  // "is this project actually set up?", which is what someone running `doctor` after
1711
1817
  // an install is asking. There is deliberately no "invocation context" flag to gate
1712
1818
  // the ordering on — the report has a single caller and carries no context, so
@@ -1742,6 +1848,27 @@ export async function runDoctorCli(argv, overrides = {}) {
1742
1848
  log(formatInstallStatusFallbackReport());
1743
1849
  }
1744
1850
  }
1851
+ // Advisory "Project identity and root" section (BAPI-1318). Read-only: it
1852
+ // validates each explicit BAPI_PROJECT_ROOT the way the server does and
1853
+ // compares .bridge/config's repo_name with each project MCP config. Placed
1854
+ // right after Install status without reordering any existing section; a throw
1855
+ // renders a sanitized line, and the exit code is never affected.
1856
+ if (overrides.projectIdentity !== false) {
1857
+ try {
1858
+ const injectedFs = deps;
1859
+ const identityDeps = {
1860
+ env: overrides.projectIdentity?.env ?? deps.env,
1861
+ cwd: overrides.projectIdentity?.cwd ?? deps.cwd,
1862
+ readFile: overrides.projectIdentity?.readFile ?? injectedFs.readFile ?? ((p) => readFile(p, "utf-8")),
1863
+ statDir: overrides.projectIdentity?.statDir ?? ((p) => stat(p)),
1864
+ };
1865
+ const collect = overrides.projectIdentity?.collect ?? collectProjectIdentityAndRoot;
1866
+ log(formatProjectIdentityAndRootReport(await collect(identityDeps)));
1867
+ }
1868
+ catch {
1869
+ log(formatProjectIdentityAndRootFallbackReport());
1870
+ }
1871
+ }
1745
1872
  if (parsed.options.agentName === "codex") {
1746
1873
  const readCodexFile = deps.readFile ?? ((p) => readFile(p, "utf-8"));
1747
1874
  const profile = await inspectBridgeCodexProfile(codexHome(deps.env), readCodexFile);
@@ -1816,7 +1943,7 @@ export async function runDoctorCli(argv, overrides = {}) {
1816
1943
  const launcherDeps = {
1817
1944
  cwd: overrides.launcherProbe?.cwd ?? deps.cwd,
1818
1945
  readFile: overrides.launcherProbe?.readFile ?? ((p) => readFile(p, "utf-8")),
1819
- probeNpxNoInstall: overrides.launcherProbe?.probeNpxNoInstall ?? probeNpxNoInstallDefault,
1946
+ probeNpxNoInstall: overrides.launcherProbe?.probeNpxNoInstall ?? ((spec) => probeNpxNoInstallDefault(spec)),
1820
1947
  };
1821
1948
  const launcherInspections = await inspectLauncherCache(launcherDeps);
1822
1949
  log(formatLauncherCacheReport(launcherInspections));
@@ -2000,8 +2127,7 @@ export async function runDoctorCli(argv, overrides = {}) {
2000
2127
  ((p) => readFile(p, "utf-8")),
2001
2128
  hostname: overrides.launchProfiles?.hostname ?? os.hostname,
2002
2129
  }));
2003
- if (report !== null)
2004
- log(report);
2130
+ log(report);
2005
2131
  }
2006
2132
  catch {
2007
2133
  /* advisory only: an unexpected failure drops the section, never the doctor */
@@ -0,0 +1,12 @@
1
+ /**
2
+ * BAPI-1285: the one epic-key pattern `drive-epic` and plan discovery share.
3
+ *
4
+ * It lives in its own module so `plan-discovery.ts` can import it without
5
+ * importing `drive-epic.ts`, which imports discovery — a cycle.
6
+ *
7
+ * This is deliberately the narrower CLI grammar. Bundle finalization accepts some
8
+ * underscore or digit project prefixes, but `drive-epic` already rejects them, so
9
+ * metadata naming such a key can never be discovered through this CLI.
10
+ */
11
+ /** Jira epic key shape. Matches the epic-key positional rule exactly. */
12
+ export const EPIC_KEY_PATTERN = /^[A-Z]+-[0-9]+$/;