@bridge_gpt/mcp-server 0.2.34 → 0.2.37

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 (109) hide show
  1. package/README.md +490 -366
  2. package/build/agent-capabilities/probe-context.js +8 -1
  3. package/build/agent-capabilities/probes.js +7 -1
  4. package/build/agents.generated.js +1 -1
  5. package/build/base-url.js +79 -0
  6. package/build/bridge-api-urls.js +9 -0
  7. package/build/chain-orchestrator.js +93 -15
  8. package/build/claude-review-workflow.js +264 -0
  9. package/build/claude-user-config-doctor.js +317 -0
  10. package/build/cli-release.js +53 -0
  11. package/build/commands.generated.js +6 -5
  12. package/build/conductor/bridge-api-client.js +393 -4
  13. package/build/conductor/deny-enforcement-preflight.js +1 -0
  14. package/build/conductor/done-gate.js +44 -5
  15. package/build/conductor/epic-reconcile.js +6 -0
  16. package/build/conductor/install-doctor.js +462 -0
  17. package/build/conductor-bin.js +3 -3
  18. package/build/conductor-bundle-artifacts.js +37 -15
  19. package/build/credential-store.js +205 -4
  20. package/build/direct-ticket-tools.js +70 -0
  21. package/build/doctor.js +473 -81
  22. package/build/executor/cli.js +83 -6
  23. package/build/executor/credentials.js +39 -11
  24. package/build/executor/deps.js +32 -1
  25. package/build/executor/env.js +71 -28
  26. package/build/executor/heartbeat.js +138 -17
  27. package/build/executor/http-client.js +49 -8
  28. package/build/executor/index.js +4 -0
  29. package/build/executor/job-errors.js +4 -0
  30. package/build/executor/job-runner.js +530 -20
  31. package/build/executor/observation.js +130 -0
  32. package/build/executor/permissions.js +114 -8
  33. package/build/executor/preflight.js +127 -8
  34. package/build/executor/prompt-spec.js +51 -0
  35. package/build/executor/runner.js +23 -2
  36. package/build/executor/service-unit.js +876 -0
  37. package/build/executor/test-clock.js +75 -3
  38. package/build/executor/types.js +4 -18
  39. package/build/executor/worker-command.js +73 -12
  40. package/build/executor/worker-config-isolation.js +287 -0
  41. package/build/executor/worker-finalization.js +68 -14
  42. package/build/executor/worktree.js +46 -4
  43. package/build/index.js +1039 -237
  44. package/build/init.js +503 -111
  45. package/build/install-bridge-conductor.js +491 -0
  46. package/build/install-bridge.js +1188 -247
  47. package/build/install-reexec.js +233 -0
  48. package/build/launcher-config-inspection.js +351 -0
  49. package/build/mcp-host-config.js +11 -1
  50. package/build/mcp-install-state.js +32 -0
  51. package/build/mcp-invoke.js +49 -6
  52. package/build/mcp-provisioning.js +52 -13
  53. package/build/mcp-registration-doctor.js +14 -5
  54. package/build/notifications.js +553 -0
  55. package/build/pipeline-orchestrator.js +146 -4
  56. package/build/pipeline-utils.js +3 -0
  57. package/build/pipelines.generated.js +32 -13
  58. package/build/plan-execution-ledger.js +550 -0
  59. package/build/plan-phase-routing.js +272 -0
  60. package/build/plane/alembic-head.js +110 -0
  61. package/build/plane/build-freshness.js +167 -0
  62. package/build/plane/cli.js +480 -0
  63. package/build/plane/defaults.js +266 -0
  64. package/build/plane/manifest.js +377 -0
  65. package/build/plane/member-logs.js +147 -0
  66. package/build/plane/member-roster.js +147 -0
  67. package/build/plane/preflight.js +289 -0
  68. package/build/plane/shutdown.js +195 -0
  69. package/build/plane/status.js +125 -0
  70. package/build/plane/supervisor.js +569 -0
  71. package/build/plane/test-fakes.js +156 -0
  72. package/build/plane/types.js +75 -0
  73. package/build/readme.generated.js +1 -1
  74. package/build/run-unit-tests-launcher.js +259 -0
  75. package/build/setup-epic.js +772 -28
  76. package/build/sfcc/log-gate.js +38 -11
  77. package/build/sfcc/log-query.js +55 -15
  78. package/build/sfcc/ocapi-shape.js +51 -14
  79. package/build/sfcc/output.js +41 -11
  80. package/build/sfcc/permissions.js +24 -2
  81. package/build/sfcc/read-projection.js +181 -0
  82. package/build/sfcc/read-result.js +158 -0
  83. package/build/sfcc/reads-custom-object-def.js +29 -18
  84. package/build/sfcc/reads-site-preference.js +75 -29
  85. package/build/sfcc/reads-system-object.js +40 -34
  86. package/build/sfcc/sfcc-result.js +106 -0
  87. package/build/sfcc/tool-wrapper.js +56 -13
  88. package/build/sfcc/write-grants.js +45 -22
  89. package/build/sfcc/write-guard.js +21 -13
  90. package/build/sfcc/write-result.js +61 -14
  91. package/build/sfcc/write-tool-common.js +126 -32
  92. package/build/sfcc/writes-system-object.js +11 -50
  93. package/build/start-tickets-prereqs.js +129 -0
  94. package/build/start-tickets.js +17 -13
  95. package/build/ticket-backend-metadata.js +59 -0
  96. package/build/ticket-key-utils.js +92 -0
  97. package/build/tool-error-envelope.js +71 -0
  98. package/build/tool-surface-gating.js +72 -0
  99. package/build/update-status.js +102 -0
  100. package/build/upgrade-advice.js +47 -0
  101. package/build/upgrade-cli.js +412 -110
  102. package/build/version.generated.js +1 -1
  103. package/build/worktree-core.js +73 -0
  104. package/docs/CONDUCTOR.md +132 -6
  105. package/docs/install/mcp-tool-integrations.md +29 -21
  106. package/package.json +9 -6
  107. package/pipelines/implement-ticket.json +19 -4
  108. package/build/conductor/supervisor-judgment-python.js +0 -141
  109. package/build/conductor/supervisor-judgment.js +0 -215
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Installer-owned self-re-exec from `@latest` (BAPI-714, Group B).
3
+ *
4
+ * WHY THIS EXISTS. The documented setup command dropped `@latest`
5
+ * (`npx -y @bridge_gpt/mcp-server install`), and an UNPINNED npx spec silently
6
+ * reuses a stale cached copy — the verified bug recorded in `init.ts:32-38`,
7
+ * where 0.2.6 persisted across an upgrade. So the install path must do for itself
8
+ * what `upgrade` already does: look up the latest published version and, when the
9
+ * local copy is strictly older, hand off to `@latest`.
10
+ *
11
+ * THREE PROPERTIES ARE LOAD-BEARING.
12
+ *
13
+ * 1. FAIL OPEN, ALWAYS. `upgrade` can afford to fail; `install` is someone's
14
+ * first contact with Bridge. An unreachable, slow, non-2xx, or malformed
15
+ * registry means "run the local copy" — never an error, prompt, or stall.
16
+ * 2. `stdio: "inherit"` IS NOT STYLISTIC. `promptSecret`/`promptLine`/
17
+ * `promptMultiSelect` are wired only when `process.stdin.isTTY`
18
+ * (`install-bridge.ts:1195`, `:1224-1226`); non-inherited stdio silently
19
+ * strips all three and turns an interactive install into a non-interactive
20
+ * one with no error.
21
+ * 3. THE SENTINEL IS STRIPPED BEFORE *ANY* ARGV INTERPRETATION. Two verified
22
+ * reasons: `parseInstallBridgeArgs` rejects unrecognized arguments, so a
23
+ * leftover `--internal-reexec` is an argument error on the child run; and
24
+ * `install-bridge.ts:1690` computes `isBareInvocation = argv.length === 0`,
25
+ * so a leftover sentinel makes the child take a DIFFERENT onboarding branch
26
+ * silently. Its presence is also the terminating condition, so exactly one
27
+ * re-exec is possible per invocation.
28
+ *
29
+ * SECRET DISCIPLINE. Child argv is process-visible (`ps`), so `--api-key <value>`
30
+ * and a VALUED `--invite <token>` are lifted out of argv and forwarded only
31
+ * through the child ENVIRONMENT as `BAPI_API_KEY` / `BAPI_INVITE`. A value-less
32
+ * `--invite` marker is deliberately RETAINED in child argv: it is what selects
33
+ * bootstrap-invite mode, and dropping it would leave `argv.length === 0` and send
34
+ * the child down the bare-onboarding branch instead. The failure diagnostic is a
35
+ * fixed string — it interpolates no argv, no environment value, and no exception
36
+ * text.
37
+ */
38
+ import { spawn } from "child_process";
39
+ import { VERSION } from "./version.generated.js";
40
+ import { isNewerVersion } from "./update-check.js";
41
+ import { fetchLatestVersion } from "./cli-release.js";
42
+ import { runInstallBridgeCli } from "./install-bridge.js";
43
+ /** The sentinel that marks an already-re-exec'd child. Matches `upgrade-cli.ts`. */
44
+ export const INSTALL_REEXEC_SENTINEL = "--internal-reexec";
45
+ /**
46
+ * The ONLY text emitted when the hand-off itself fails. Fixed by construction —
47
+ * no argv, no environment value, no raw exception message, no credential.
48
+ */
49
+ export const INSTALL_REEXEC_FAILURE_MESSAGE = "Bridge API install failed: could not run the latest installer.";
50
+ /**
51
+ * Remove EVERY `--internal-reexec` occurrence and report whether any was present.
52
+ *
53
+ * Called before nested-command detection, before the re-exec decision, and before
54
+ * anything is handed to `runInstallBridgeCli` — the sentinel must never reach the
55
+ * installer's strict argument parser or its bare-invocation computation.
56
+ */
57
+ export function stripInternalReexecSentinels(argv) {
58
+ const args = [];
59
+ let sentinelPresent = false;
60
+ for (const arg of argv) {
61
+ if (arg === INSTALL_REEXEC_SENTINEL) {
62
+ sentinelPresent = true;
63
+ continue;
64
+ }
65
+ args.push(arg);
66
+ }
67
+ return { args, sentinelPresent };
68
+ }
69
+ /**
70
+ * Lift secret-valued flags out of argv and into a child-environment overlay.
71
+ *
72
+ * Mirrors `parseInstallBridgeArgs` token-for-token so the two never disagree:
73
+ *
74
+ * - `--api-key=<value>` / `--api-key <value>` → `BAPI_API_KEY`, flag AND value
75
+ * removed. The installer resolves the key from the environment, and
76
+ * `hasEnvApiKey` keeps the bare-interactive chooser suppressed exactly as the
77
+ * flag did.
78
+ * - `--invite=<token>` / `--invite <token>` → `BAPI_INVITE`, with a VALUE-LESS
79
+ * `--invite` left in argv. That marker still selects bootstrap-invite mode and
80
+ * the installer falls back to `BAPI_INVITE` for the token.
81
+ * - MALFORMED forms (`--api-key` at end of argv, `--api-key=`, `--invite=`) are
82
+ * left untouched so the installer's own parser produces the established
83
+ * argument error rather than this layer inventing a new one.
84
+ *
85
+ * Every other argument is preserved byte-for-byte in its original order. This is
86
+ * deliberately NOT `sanitizePrewarmEnv()` from `install-bridge.ts`: that helper
87
+ * STRIPS installer credentials for a `--version` probe and has the opposite
88
+ * contract from this hand-off.
89
+ */
90
+ export function prepareInstallReexecArguments(argv) {
91
+ const forwardedArgs = [];
92
+ const secretEnv = {};
93
+ for (let i = 0; i < argv.length; i++) {
94
+ const arg = argv[i];
95
+ if (arg === "--api-key" || arg.startsWith("--api-key=")) {
96
+ let value;
97
+ let consumedNext = false;
98
+ if (arg.startsWith("--api-key=")) {
99
+ value = arg.slice("--api-key=".length);
100
+ }
101
+ else if (i + 1 < argv.length) {
102
+ value = argv[i + 1];
103
+ consumedNext = true;
104
+ }
105
+ // Blank / absent value == malformed: hand it to the installer's parser.
106
+ if (value === undefined || value.length === 0) {
107
+ forwardedArgs.push(arg);
108
+ continue;
109
+ }
110
+ secretEnv.BAPI_API_KEY = value;
111
+ if (consumedNext)
112
+ i += 1;
113
+ continue;
114
+ }
115
+ if (arg === "--invite" || arg.startsWith("--invite=")) {
116
+ if (arg.startsWith("--invite=")) {
117
+ const value = arg.slice("--invite=".length);
118
+ if (value.length === 0) {
119
+ // Malformed: leave it for the installer's parser.
120
+ forwardedArgs.push(arg);
121
+ continue;
122
+ }
123
+ secretEnv.BAPI_INVITE = value;
124
+ forwardedArgs.push("--invite");
125
+ continue;
126
+ }
127
+ // Bare `--invite`: only a following NON-flag token is its value, exactly as
128
+ // `parseInstallBridgeArgs` reads it.
129
+ const next = argv[i + 1];
130
+ forwardedArgs.push("--invite");
131
+ if (typeof next === "string" && !next.startsWith("-") && next.length > 0) {
132
+ secretEnv.BAPI_INVITE = next;
133
+ i += 1;
134
+ }
135
+ continue;
136
+ }
137
+ forwardedArgs.push(arg);
138
+ }
139
+ return { forwardedArgs, secretEnv };
140
+ }
141
+ /**
142
+ * The public `install` / legacy `install-bridge` entry point.
143
+ *
144
+ * Fail-open ordering, in the only order that is safe:
145
+ *
146
+ * 1. Strip sentinels (before anything reads argv).
147
+ * 2. If a sentinel was present, or the first remaining token is `conductor`,
148
+ * delegate straight to the local installer — no registry lookup, no spawn.
149
+ * The nested `install conductor` surface is intercepted inside
150
+ * `runInstallBridgeCli`, so it must reach it as `["conductor", ...]` rather
151
+ * than being collapsed into bare onboarding.
152
+ * 3. Otherwise look up the latest version, defaulting the comparison target to
153
+ * the local `VERSION` so an unusable answer changes nothing.
154
+ * 4. Re-exec only for a STRICTLY newer published version.
155
+ */
156
+ export async function runInstallBridgeWithLatestCli(argv, deps = {}) {
157
+ const resolveLatest = deps.fetchLatestVersion ?? (() => fetchLatestVersion());
158
+ const runLocal = deps.runInstallBridgeCli ?? runInstallBridgeCli;
159
+ const doSpawn = deps.spawn ?? spawn;
160
+ const env = deps.env ?? process.env;
161
+ const platform = deps.platform ?? process.platform;
162
+ const cwd = deps.cwd ?? process.cwd();
163
+ const errorLog = deps.errorLog ?? ((message) => console.error(message));
164
+ const localVersion = deps.localVersion ?? VERSION;
165
+ const { args: cleanedArgs, sentinelPresent } = stripInternalReexecSentinels(argv);
166
+ // Terminating condition, and the nested-conductor bypass. Both skip the
167
+ // registry entirely.
168
+ if (sentinelPresent || cleanedArgs[0] === "conductor") {
169
+ return runLocal(cleanedArgs);
170
+ }
171
+ // Default the comparison target to the local version: an unusable registry
172
+ // answer must be indistinguishable from "already current".
173
+ let latestVersion = localVersion;
174
+ let fetched = null;
175
+ try {
176
+ fetched = await resolveLatest();
177
+ }
178
+ catch {
179
+ fetched = null;
180
+ }
181
+ if (fetched)
182
+ latestVersion = fetched;
183
+ if (!isNewerVersion(localVersion, latestVersion)) {
184
+ return runLocal(cleanedArgs);
185
+ }
186
+ const { forwardedArgs, secretEnv } = prepareInstallReexecArguments(cleanedArgs);
187
+ const npxCmd = platform === "win32" ? "npx.cmd" : "npx";
188
+ const childArgs = [
189
+ "-y",
190
+ "@bridge_gpt/mcp-server@latest",
191
+ "install",
192
+ INSTALL_REEXEC_SENTINEL,
193
+ ...forwardedArgs,
194
+ ];
195
+ return new Promise((resolve) => {
196
+ let settled = false;
197
+ const settle = (code) => {
198
+ if (settled)
199
+ return;
200
+ settled = true;
201
+ resolve(code);
202
+ };
203
+ const fail = () => {
204
+ errorLog(INSTALL_REEXEC_FAILURE_MESSAGE);
205
+ settle(1);
206
+ };
207
+ let child;
208
+ try {
209
+ child = doSpawn(npxCmd, childArgs, {
210
+ shell: false,
211
+ stdio: "inherit",
212
+ cwd,
213
+ // Explicit CLI values win over an inherited value of the same key.
214
+ env: { ...env, ...secretEnv },
215
+ });
216
+ }
217
+ catch {
218
+ fail();
219
+ return;
220
+ }
221
+ child.on("close", (code) => {
222
+ // A signal termination or an absent numeric code is an abnormal exit.
223
+ if (typeof code === "number") {
224
+ settle(code);
225
+ return;
226
+ }
227
+ fail();
228
+ });
229
+ child.on("error", () => {
230
+ fail();
231
+ });
232
+ });
233
+ }
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Shared, read-only inspection of every project-local MCP launcher config
3
+ * (BAPI-728).
4
+ *
5
+ * Before this module, three callers each carried their own idea of what a
6
+ * launcher config looks like:
7
+ *
8
+ * - `doctor.ts` parsed the pin correctly and reported `STALE-PINNED`.
9
+ * - `upgrade-cli.ts` kept a duplicate path list and never read the pin at all,
10
+ * deciding from the running process `VERSION` instead — which is why a
11
+ * twenty-release repin reported itself as `0.2.36 -> 0.2.36`.
12
+ * - `init.ts` repaired the pin as an undocumented side effect of writing.
13
+ *
14
+ * This module is the single authority for *what a config currently says*. It is
15
+ * strictly read-only: it never writes, never spawns, never touches the network,
16
+ * and never resolves credentials. Callers layer their own concerns on top —
17
+ * `doctor` adds a read-only npx cache probe, `init` performs the mutation,
18
+ * `upgrade` renders the transition report and owns exit status.
19
+ *
20
+ * Secret safety: an inspection result carries only sanitized launcher facts —
21
+ * relative path, launcher shape, the package spec token, and versions. It never
22
+ * carries config bodies, `env` maps, credential values, or raw error text.
23
+ */
24
+ import path from "path";
25
+ /** The npm package every Bridge launcher spec names. */
26
+ export const BRIDGE_PACKAGE_NAME = "@bridge_gpt/mcp-server";
27
+ /**
28
+ * The subcommand token that marks a worktree shim registration. A shim points at
29
+ * a sibling worktree's server rather than a published package, so no pin applies
30
+ * and no rewrite is ever safe (BAPI-714 / worktree provisioning).
31
+ */
32
+ const WORKTREE_SHIM_TOKEN = "mcp-invoke";
33
+ /** The MCP server entry key every Bridge registration uses. */
34
+ export const BRIDGE_ENTRY_KEY = "bridge-api";
35
+ /**
36
+ * Every project-local MCP config Bridge registers into. Exported so `doctor`,
37
+ * `init`, and `upgrade` can no longer drift apart on which paths exist or which
38
+ * root key each one uses. Callers that need a different *order* should map over
39
+ * their own list and use {@link launcherTargetFor} for the metadata.
40
+ */
41
+ export const LAUNCHER_CONFIG_TARGETS = [
42
+ { relPath: ".mcp.json", topLevelKey: "mcpServers" },
43
+ { relPath: ".cursor/mcp.json", topLevelKey: "mcpServers" },
44
+ { relPath: ".vscode/mcp.json", topLevelKey: "servers" },
45
+ ];
46
+ /** Look up a target's metadata by relative path, or `undefined` if unknown. */
47
+ export function launcherTargetFor(relPath) {
48
+ return LAUNCHER_CONFIG_TARGETS.find((t) => t.relPath === relPath);
49
+ }
50
+ // ---------------------------------------------------------------------------
51
+ // Exact-version parsing and comparison
52
+ // ---------------------------------------------------------------------------
53
+ /**
54
+ * Strict exact-release form: `MAJOR.MINOR.PATCH`, nothing else. Deliberately
55
+ * narrower than semver at large — a prerelease, build metadata, a `v` prefix, a
56
+ * two-part `0.2`, a range (`^0.2.0`), or a dist-tag (`beta`) is NOT an exact
57
+ * version and must never be treated as one. Everything the package publishes
58
+ * takes this form, so anything else in a config is something a human wrote and
59
+ * an automated repin has no business silently rewriting.
60
+ */
61
+ const EXACT_VERSION_RE = /^(\d+)\.(\d+)\.(\d+)$/;
62
+ /** Parse an exact `MAJOR.MINOR.PATCH` string, or return null for any other form. */
63
+ export function parseExactVersion(raw) {
64
+ if (typeof raw !== "string")
65
+ return null;
66
+ const match = EXACT_VERSION_RE.exec(raw.trim());
67
+ if (!match)
68
+ return null;
69
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
70
+ }
71
+ /** True when `value` is an exact `MAJOR.MINOR.PATCH` release string. */
72
+ export function isExactVersion(raw) {
73
+ return parseExactVersion(raw) !== null;
74
+ }
75
+ /**
76
+ * Compare two exact versions: negative when `a < b`, zero when equal, positive
77
+ * when `a > b`. Returns null when either side is not an exact version, so a
78
+ * caller can never accidentally order a range against a release.
79
+ */
80
+ export function compareExactVersions(a, b) {
81
+ const left = parseExactVersion(a);
82
+ const right = parseExactVersion(b);
83
+ if (!left || !right)
84
+ return null;
85
+ for (let i = 0; i < 3; i++) {
86
+ if (left[i] !== right[i])
87
+ return left[i] - right[i];
88
+ }
89
+ return 0;
90
+ }
91
+ /**
92
+ * Extract the Bridge package token from a launcher args array.
93
+ *
94
+ * The package name is itself scoped (`@scope/name`), so the version delimiter is
95
+ * the FIRST `@` *after* the package name — not the last `@` in the string.
96
+ * `@bridge_gpt/mcp-server` is bare; `@bridge_gpt/mcp-server@0.2.16` pins
97
+ * `0.2.16`. A non-Bridge package token is never matched.
98
+ */
99
+ export function parseLauncherPin(args) {
100
+ if (!Array.isArray(args))
101
+ return null;
102
+ for (const arg of args) {
103
+ if (typeof arg !== "string")
104
+ continue;
105
+ if (arg === BRIDGE_PACKAGE_NAME) {
106
+ return { spec: arg, specifier: null, exactVersion: null };
107
+ }
108
+ if (arg.startsWith(`${BRIDGE_PACKAGE_NAME}@`)) {
109
+ const specifier = arg.slice(BRIDGE_PACKAGE_NAME.length + 1).trim();
110
+ if (specifier.length === 0) {
111
+ return { spec: arg, specifier: null, exactVersion: null };
112
+ }
113
+ return {
114
+ spec: arg,
115
+ specifier,
116
+ exactVersion: isExactVersion(specifier) ? specifier : null,
117
+ };
118
+ }
119
+ }
120
+ return null;
121
+ }
122
+ /** True when the args array carries the worktree-shim subcommand token. */
123
+ export function isWorktreeShimArgs(args) {
124
+ return Array.isArray(args) && args.some((a) => a === WORKTREE_SHIM_TOKEN);
125
+ }
126
+ /** Human-readable text for each reason code (no dynamic content). */
127
+ const REASON_TEXT = {
128
+ "file-unreadable": "the file exists but could not be read",
129
+ "invalid-json": "the file is not valid JSON",
130
+ "root-not-object": "the JSON root is not an object",
131
+ "servers-not-object": "the servers section is not an object",
132
+ "entry-not-object": "the bridge-api entry is not an object",
133
+ "no-package-token": "the bridge-api launcher has no recognizable @bridge_gpt/mcp-server token",
134
+ "version-range": "the launcher pin is a version range, which an automated repin must not rewrite",
135
+ "non-release-specifier": "the launcher pin is a dist-tag or channel, not an exact release",
136
+ "malformed-version": "the launcher pin is not a valid MAJOR.MINOR.PATCH release",
137
+ "pinned-ahead-of-target": "the launcher pin is newer than the target version and must not be downgraded",
138
+ };
139
+ /** Render a reason code as sanitized prose. */
140
+ export function describeLauncherReason(reason) {
141
+ return REASON_TEXT[reason];
142
+ }
143
+ /** True for the two actions that must abort a mutation run before any write. */
144
+ export function isPrerequisiteFailure(inspection) {
145
+ return inspection.action === "invalid" || inspection.action === "unsupported";
146
+ }
147
+ /**
148
+ * True when this target carries a standard launcher whose pin participates in
149
+ * the "is everything already current?" question. Worktree shims, inactive
150
+ * targets, and absent files deliberately do not.
151
+ */
152
+ export function isApplicableStandardLauncher(inspection) {
153
+ return (inspection.shape === "standard" &&
154
+ (inspection.action === "repin" ||
155
+ inspection.action === "already-current" ||
156
+ inspection.action === "normalize-unpinned"));
157
+ }
158
+ /**
159
+ * Inspect every project-local MCP config's `bridge-api` launcher, read-only.
160
+ *
161
+ * Every target always produces exactly one result, so a caller can render one
162
+ * line per config without re-deriving which paths it looked at. Only an `ENOENT`
163
+ * read is treated as absence — a permission error on an existing file is an
164
+ * `invalid` prerequisite failure, never a "missing file we can freshly create".
165
+ */
166
+ export async function inspectLauncherConfigs(options) {
167
+ const targets = options.targets ?? LAUNCHER_CONFIG_TARGETS;
168
+ const results = [];
169
+ for (const target of targets) {
170
+ results.push(await inspectOne(target, options));
171
+ }
172
+ return results;
173
+ }
174
+ async function inspectOne(target, options) {
175
+ const { targetVersion } = options;
176
+ const base = {
177
+ relPath: target.relPath,
178
+ topLevelKey: target.topLevelKey,
179
+ targetVersion,
180
+ spec: null,
181
+ currentVersion: null,
182
+ };
183
+ let raw;
184
+ try {
185
+ raw = await options.readFile(path.join(options.cwd, target.relPath));
186
+ }
187
+ catch (err) {
188
+ const code = err && typeof err === "object" ? err.code : undefined;
189
+ if (code === "ENOENT") {
190
+ const creatable = options.isCreatable?.(target.relPath) ?? false;
191
+ return {
192
+ ...base,
193
+ filePresent: false,
194
+ bridgeEntryPresent: false,
195
+ shape: "none",
196
+ action: creatable ? "create" : "skip-inactive",
197
+ };
198
+ }
199
+ // Present-but-unreadable is NOT absence. Creating a fresh file over a config
200
+ // we merely failed to read would silently destroy a working registration.
201
+ return {
202
+ ...base,
203
+ filePresent: true,
204
+ bridgeEntryPresent: false,
205
+ shape: "none",
206
+ action: "invalid",
207
+ reason: "file-unreadable",
208
+ };
209
+ }
210
+ let parsed;
211
+ try {
212
+ parsed = JSON.parse(raw);
213
+ }
214
+ catch {
215
+ return {
216
+ ...base,
217
+ filePresent: true,
218
+ bridgeEntryPresent: false,
219
+ shape: "none",
220
+ action: "invalid",
221
+ reason: "invalid-json",
222
+ };
223
+ }
224
+ if (!isPlainObject(parsed)) {
225
+ return {
226
+ ...base,
227
+ filePresent: true,
228
+ bridgeEntryPresent: false,
229
+ shape: "none",
230
+ action: "invalid",
231
+ reason: "root-not-object",
232
+ };
233
+ }
234
+ const servers = parsed[target.topLevelKey];
235
+ if (servers !== undefined && !isPlainObject(servers)) {
236
+ return {
237
+ ...base,
238
+ filePresent: true,
239
+ bridgeEntryPresent: false,
240
+ shape: "none",
241
+ action: "invalid",
242
+ reason: "servers-not-object",
243
+ };
244
+ }
245
+ const entry = isPlainObject(servers)
246
+ ? servers[BRIDGE_ENTRY_KEY]
247
+ : undefined;
248
+ if (entry === undefined) {
249
+ // A valid host config that simply has no Bridge registration yet. Other
250
+ // servers in the same file are deliberately not inspected or reported.
251
+ return {
252
+ ...base,
253
+ filePresent: true,
254
+ bridgeEntryPresent: false,
255
+ shape: "none",
256
+ action: "add",
257
+ };
258
+ }
259
+ if (!isPlainObject(entry)) {
260
+ return {
261
+ ...base,
262
+ filePresent: true,
263
+ bridgeEntryPresent: true,
264
+ shape: "custom",
265
+ action: "invalid",
266
+ reason: "entry-not-object",
267
+ };
268
+ }
269
+ const args = entry.args;
270
+ // Worktree shims are detected BEFORE any package-token requirement: the
271
+ // absolute-build-path form (`node /abs/path/build/index.js mcp-invoke`) has no
272
+ // package token at all, and must not be misread as an unsupported launcher.
273
+ if (isWorktreeShimArgs(args)) {
274
+ return {
275
+ ...base,
276
+ filePresent: true,
277
+ bridgeEntryPresent: true,
278
+ shape: "worktree-shim",
279
+ action: "skip-worktree-shim",
280
+ };
281
+ }
282
+ const pin = parseLauncherPin(args);
283
+ if (!pin) {
284
+ return {
285
+ ...base,
286
+ filePresent: true,
287
+ bridgeEntryPresent: true,
288
+ shape: "custom",
289
+ action: "unsupported",
290
+ reason: "no-package-token",
291
+ };
292
+ }
293
+ const common = {
294
+ ...base,
295
+ filePresent: true,
296
+ bridgeEntryPresent: true,
297
+ shape: "standard",
298
+ spec: pin.spec,
299
+ };
300
+ // Bare and explicit `@latest` are the two forms an exact-pin policy normalizes
301
+ // rather than rejects — both mean "whatever npx resolves", which is exactly the
302
+ // non-determinism BAPI-429 pinned away from.
303
+ if (pin.specifier === null || pin.specifier === "latest") {
304
+ return { ...common, action: "normalize-unpinned" };
305
+ }
306
+ if (!isExactVersion(pin.specifier)) {
307
+ return {
308
+ ...common,
309
+ action: "unsupported",
310
+ reason: classifyNonExactSpecifier(pin.specifier),
311
+ };
312
+ }
313
+ const comparison = compareExactVersions(pin.specifier, targetVersion);
314
+ if (comparison === null) {
315
+ // The TARGET is not an exact release. Refuse rather than guess.
316
+ return {
317
+ ...common,
318
+ currentVersion: pin.specifier,
319
+ action: "unsupported",
320
+ reason: "malformed-version",
321
+ };
322
+ }
323
+ if (comparison > 0) {
324
+ return {
325
+ ...common,
326
+ currentVersion: pin.specifier,
327
+ action: "ahead",
328
+ reason: "pinned-ahead-of-target",
329
+ };
330
+ }
331
+ return {
332
+ ...common,
333
+ currentVersion: pin.specifier,
334
+ action: comparison === 0 ? "already-current" : "repin",
335
+ };
336
+ }
337
+ /** Classify why a non-exact specifier cannot be treated as a release pin. */
338
+ function classifyNonExactSpecifier(specifier) {
339
+ if (/^[\^~]|^[<>=]/.test(specifier) || specifier.includes(" - ") || specifier.includes("||")) {
340
+ return "version-range";
341
+ }
342
+ // A leading digit means the author meant a version and got the shape wrong
343
+ // (`0.2`, `1.2.3.4`); anything else is a dist-tag or channel (`beta`, `next`).
344
+ if (/^v?\d/.test(specifier))
345
+ return "malformed-version";
346
+ return "non-release-specifier";
347
+ }
348
+ /** True for a non-null, non-array object. */
349
+ function isPlainObject(value) {
350
+ return typeof value === "object" && value !== null && !Array.isArray(value);
351
+ }
@@ -28,6 +28,7 @@
28
28
  * rather than reserialize unrelated content.
29
29
  */
30
30
  import { VERSION } from "./version.generated.js";
31
+ import { refreshBridgeApiPackageSpec } from "./init.js";
31
32
  // ---------------------------------------------------------------------------
32
33
  // Path helpers (POSIX-normalized; module stays free of node:path).
33
34
  // ---------------------------------------------------------------------------
@@ -119,7 +120,16 @@ export function mergeJsonHostConfig(existing, target, adaptedEntry) {
119
120
  const root = rootRaw && typeof rootRaw === "object" && !Array.isArray(rootRaw)
120
121
  ? { ...rootRaw }
121
122
  : {};
122
- root["bridge-api"] = adaptedEntry;
123
+ // BAPI-714 (Group C): an UPDATE to a pre-existing Bridge registration keeps that
124
+ // registration's own launcher args composition — only the package-spec token is
125
+ // refreshed — so a global/manual host config written before `serve` existed is
126
+ // never migrated to it. A registration this path CREATES receives the adapted
127
+ // template args, `serve` included.
128
+ const prior = root["bridge-api"];
129
+ const preservedArgs = prior && typeof prior === "object" && !Array.isArray(prior)
130
+ ? refreshBridgeApiPackageSpec(prior.args, currentLauncherSpec())
131
+ : null;
132
+ root["bridge-api"] = preservedArgs ? { ...adaptedEntry, args: preservedArgs } : adaptedEntry;
123
133
  merged[target.topLevelKey] = root;
124
134
  return merged;
125
135
  }
@@ -152,6 +152,10 @@ export async function writeMcpInstallState(cwd, input, deps) {
152
152
  projectConfigPaths: normalizeProjectPaths(input.projectConfigPaths),
153
153
  };
154
154
  const finalPath = installStatePath(cwd);
155
+ return persistMcpInstallState(cwd, state, finalPath, deps);
156
+ }
157
+ /** Shared serialize-and-atomically-persist tail for the two writers below. */
158
+ async function persistMcpInstallState(cwd, state, finalPath, deps) {
155
159
  const tempPath = installStateTempPath(cwd);
156
160
  const serialized = serializeMcpInstallState(state);
157
161
  try {
@@ -173,3 +177,31 @@ export async function writeMcpInstallState(cwd, input, deps) {
173
177
  }
174
178
  return { ok: true, path: finalPath, state };
175
179
  }
180
+ /**
181
+ * Record a locally installed project artifact WITHOUT clobbering existing state.
182
+ *
183
+ * `writeMcpInstallState` rebuilds the whole document from its `input` — it does
184
+ * not merge. A caller supplying only `projectConfigPaths` would therefore write
185
+ * an EMPTY `selectedPlatforms`, silently dropping the platform roster a later
186
+ * `doctor` run reports from (BAPI-679 review, E-13). This helper reads the
187
+ * current state first and writes back the union, so the additive record stays
188
+ * additive.
189
+ *
190
+ * Only the repo-relative PATH is stored. No OAuth token, API key, GitHub
191
+ * credential, or expanded workflow secret can reach the file: the state schema
192
+ * has no field for one, and the serializer emits only schema-approved keys.
193
+ */
194
+ export async function recordInstalledProjectArtifact(cwd, relPath, deps) {
195
+ if (typeof relPath !== "string" || relPath.length === 0) {
196
+ return { ok: false, error: "artifact path must be a non-empty relative path" };
197
+ }
198
+ const existing = await readMcpInstallState(cwd, deps);
199
+ const priorPlatforms = existing.status === "valid" ? existing.state.selectedPlatforms : [];
200
+ const priorPaths = existing.status === "valid" ? existing.state.projectConfigPaths : [];
201
+ const state = {
202
+ version: MCP_INSTALL_STATE_VERSION,
203
+ selectedPlatforms: normalizePlatforms(priorPlatforms),
204
+ projectConfigPaths: normalizeProjectPaths([...priorPaths, relPath]),
205
+ };
206
+ return persistMcpInstallState(cwd, state, installStatePath(cwd), deps);
207
+ }