@bridge_gpt/mcp-server 0.2.10 → 0.2.12
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.
- package/README.md +3 -3
- package/build/commands.generated.js +6 -6
- package/build/conductor/bridge-api-client.js +2 -1
- package/build/conductor/cli.js +16 -16
- package/build/conductor/doctor.js +1 -1
- package/build/conductor/epic-reconcile.js +213 -16
- package/build/conductor/epic-runtime.js +89 -6
- package/build/conductor/epic-state.js +85 -11
- package/build/conductor/errors.js +12 -0
- package/build/conductor/git-ci-types.js +10 -0
- package/build/conductor/git-producer.js +4 -4
- package/build/conductor/merge-ledger.js +7 -7
- package/build/conductor/pr-ci-producer.js +6 -6
- package/build/conductor/pr-review-producer.js +2 -2
- package/build/conductor/producer-ledger.js +5 -5
- package/build/conductor/spec-review-producer.js +88 -0
- package/build/conductor/store.js +97 -25
- package/build/conductor/supervisor-ledger.js +2 -2
- package/build/conductor/supervisor-merge.js +5 -5
- package/build/conductor/supervisor-message-relay.js +1 -1
- package/build/conductor/supervisor-runtime.js +10 -10
- package/build/conductor/taxonomy.js +5 -0
- package/build/conductor/tools.js +5 -5
- package/build/conductor-bin.js +12350 -19
- package/build/conductor-claude-hook-bin.js +167 -17
- package/build/decision-page-schema.js +26 -0
- package/build/doctor.js +200 -0
- package/build/index.js +23705 -3630
- package/build/install-bridge.js +80 -0
- package/build/pipelines.generated.js +70 -48
- package/build/readme.generated.js +1 -1
- package/build/version.generated.js +1 -1
- package/package.json +7 -4
- package/pipelines/check-ci-ticket.json +2 -2
- package/pipelines/implement-ticket.json +2 -2
- package/pipelines/learn-repository.json +84 -42
- package/smoke-test/SMOKE-TEST.md +11 -17
|
@@ -1,21 +1,171 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
2
|
+
|
|
3
|
+
// src/conductor/claude-hook.ts
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
var CONDUCTOR_HOOK_EMIT_FAILED_WARNING = "Warning: conductor hook emit failed.";
|
|
7
|
+
function resolveClaudeHookEventName(payload) {
|
|
8
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const record = payload;
|
|
12
|
+
const candidates = ["hook_event_name", "hookEventName", "event_name", "event"];
|
|
13
|
+
for (const key of candidates) {
|
|
14
|
+
const value = record[key];
|
|
15
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
16
|
+
return value.trim();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
function mapClaudeHookEventToSemanticType(eventName) {
|
|
22
|
+
switch (eventName) {
|
|
23
|
+
case "SessionStart":
|
|
24
|
+
return "run.started";
|
|
25
|
+
case "Stop":
|
|
26
|
+
return "run.stopped";
|
|
27
|
+
case "SubagentStop":
|
|
28
|
+
return "run.stopped";
|
|
29
|
+
case "Notification":
|
|
30
|
+
return "agent.notification";
|
|
31
|
+
case "PreToolUse":
|
|
32
|
+
return "tool.intent";
|
|
33
|
+
default:
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
var REQUIRED_IDENTITY_ENV = [
|
|
38
|
+
"BAPI_CONDUCTOR_RUN_ID",
|
|
39
|
+
"BAPI_CONDUCTOR_WORKER_ID",
|
|
40
|
+
"BAPI_CONDUCTOR_TICKET_KEY",
|
|
41
|
+
"BAPI_CONDUCTOR_WORKTREE_PATH"
|
|
42
|
+
];
|
|
43
|
+
function nonEmpty(value) {
|
|
44
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
45
|
+
}
|
|
46
|
+
function buildClaudeHookConductorEvent(payload, env) {
|
|
47
|
+
const eventName = resolveClaudeHookEventName(payload);
|
|
48
|
+
const type = mapClaudeHookEventToSemanticType(eventName);
|
|
49
|
+
if (type === null) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
if (env.BAPI_CONDUCTOR_ENABLED !== "1") {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
for (const key of REQUIRED_IDENTITY_ENV) {
|
|
56
|
+
if (!nonEmpty(env[key])) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const ticketKey = env.BAPI_CONDUCTOR_TICKET_KEY.trim();
|
|
61
|
+
const worktreePath = env.BAPI_CONDUCTOR_WORKTREE_PATH.trim();
|
|
62
|
+
const details = {
|
|
63
|
+
ticket_key: ticketKey,
|
|
64
|
+
worktree_path: worktreePath
|
|
65
|
+
};
|
|
66
|
+
if (nonEmpty(env.BAPI_CONDUCTOR_REPO_NAME)) {
|
|
67
|
+
details.repo = env.BAPI_CONDUCTOR_REPO_NAME.trim();
|
|
68
|
+
}
|
|
69
|
+
const raw = payload !== null && typeof payload === "object" && !Array.isArray(payload) ? payload : { payload };
|
|
70
|
+
return {
|
|
71
|
+
source: "claude-code",
|
|
72
|
+
type,
|
|
73
|
+
subject: ticketKey,
|
|
74
|
+
run_id: env.BAPI_CONDUCTOR_RUN_ID.trim(),
|
|
75
|
+
worker_id: env.BAPI_CONDUCTOR_WORKER_ID.trim(),
|
|
76
|
+
producer: "claude-code-hook",
|
|
77
|
+
observed_via: "claude-code-hook",
|
|
78
|
+
data: {
|
|
79
|
+
details,
|
|
80
|
+
raw
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function buildConductorEmitEventArgs(event) {
|
|
85
|
+
const args = ["--type", event.type, "--source", event.source];
|
|
86
|
+
if (nonEmpty(event.subject ?? void 0)) args.push("--subject", event.subject);
|
|
87
|
+
if (nonEmpty(event.run_id ?? void 0)) args.push("--run-id", event.run_id);
|
|
88
|
+
if (nonEmpty(event.worker_id ?? void 0)) args.push("--worker-id", event.worker_id);
|
|
89
|
+
if (nonEmpty(event.producer ?? void 0)) args.push("--producer", event.producer);
|
|
90
|
+
if (nonEmpty(event.observed_via ?? void 0)) {
|
|
91
|
+
args.push("--observed-via", event.observed_via);
|
|
92
|
+
}
|
|
93
|
+
args.push("--data-json-stdin", "--json");
|
|
94
|
+
return args;
|
|
16
95
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
96
|
+
function resolveConductorEmitCommand(env, emitArgs) {
|
|
97
|
+
const cliFile = env.BAPI_CONDUCTOR_CLI_FILE;
|
|
98
|
+
if (nonEmpty(cliFile)) {
|
|
99
|
+
return {
|
|
100
|
+
command: process.execPath,
|
|
101
|
+
args: [cliFile.trim(), "emit-event", ...emitArgs]
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const bin = nonEmpty(env.BAPI_CONDUCTOR_BIN) ? env.BAPI_CONDUCTOR_BIN.trim() : "conductor";
|
|
105
|
+
return { command: bin, args: ["emit-event", ...emitArgs] };
|
|
106
|
+
}
|
|
107
|
+
var defaultConductorSpawn = (command, args, input) => {
|
|
108
|
+
const result = spawnSync(command, args, {
|
|
109
|
+
input,
|
|
110
|
+
encoding: "utf-8",
|
|
111
|
+
// Never use a shell: list args + stdin keep raw payloads/secrets out of any
|
|
112
|
+
// shell-interpreted command string.
|
|
113
|
+
shell: false
|
|
114
|
+
});
|
|
115
|
+
return { status: result.status, error: result.error };
|
|
116
|
+
};
|
|
117
|
+
function emitClaudeHookEventWithConductorCli(event, deps = {}) {
|
|
118
|
+
const env = deps.env ?? process.env;
|
|
119
|
+
const spawn = deps.spawn ?? defaultConductorSpawn;
|
|
120
|
+
const emitArgs = buildConductorEmitEventArgs(event);
|
|
121
|
+
const { command, args } = resolveConductorEmitCommand(env, emitArgs);
|
|
122
|
+
const stdinPayload = JSON.stringify(event.data ?? {});
|
|
123
|
+
try {
|
|
124
|
+
const result = spawn(command, args, stdinPayload);
|
|
125
|
+
if (result.error) return false;
|
|
126
|
+
return result.status === 0;
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function runClaudeConductorHookCli(deps = {}) {
|
|
132
|
+
const env = deps.env ?? process.env;
|
|
133
|
+
const readStdin = deps.readStdin ?? (() => readStdinSync());
|
|
134
|
+
const warn = deps.warn ?? ((m) => process.stderr.write(`${m}
|
|
135
|
+
`));
|
|
136
|
+
let payload;
|
|
137
|
+
try {
|
|
138
|
+
const raw = readStdin();
|
|
139
|
+
payload = JSON.parse(raw);
|
|
140
|
+
} catch {
|
|
141
|
+
warn(CONDUCTOR_HOOK_EMIT_FAILED_WARNING);
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
let event;
|
|
145
|
+
try {
|
|
146
|
+
event = buildClaudeHookConductorEvent(payload, env);
|
|
147
|
+
} catch {
|
|
148
|
+
warn(CONDUCTOR_HOOK_EMIT_FAILED_WARNING);
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
if (event === null) {
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
const ok = emitClaudeHookEventWithConductorCli(event, { env, spawn: deps.spawn });
|
|
155
|
+
if (!ok) {
|
|
156
|
+
warn(CONDUCTOR_HOOK_EMIT_FAILED_WARNING);
|
|
157
|
+
}
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
function readStdinSync() {
|
|
161
|
+
return readFileSync(0, "utf-8");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/conductor-claude-hook-bin.ts
|
|
165
|
+
var exitCode = 0;
|
|
166
|
+
try {
|
|
167
|
+
exitCode = runClaudeConductorHookCli();
|
|
168
|
+
} catch {
|
|
169
|
+
exitCode = 0;
|
|
20
170
|
}
|
|
21
171
|
process.exit(exitCode);
|
|
@@ -192,3 +192,29 @@ export const DecisionPageInputShape = {
|
|
|
192
192
|
.describe("Confirmed improvements displayed as informational list, not submitted."),
|
|
193
193
|
};
|
|
194
194
|
export const DecisionPageInputSchema = z.object(DecisionPageInputShape);
|
|
195
|
+
// Lean schema for tool registration: keeps strict routing/output fields and
|
|
196
|
+
// defers the heavy nested arrays to a single `content` object. This removes
|
|
197
|
+
// ~1,800 tokens from the always-on core tools/list payload (BAPI-444).
|
|
198
|
+
// The handler reconstructs the flat payload and validates with DecisionPageInputSchema.
|
|
199
|
+
export const DecisionPageLeanInputShape = {
|
|
200
|
+
ticket_key: z.string().describe("Jira ticket key, e.g. BAPI-123"),
|
|
201
|
+
artifact_type: z
|
|
202
|
+
.enum(["review_decisions", "pre_ticket_planning"])
|
|
203
|
+
.optional()
|
|
204
|
+
.default("review_decisions")
|
|
205
|
+
.describe('Which flavor of page to render. "review_decisions" (default) or "pre_ticket_planning" (adds system_goals and implementation_order sections).'),
|
|
206
|
+
output_subdir: z
|
|
207
|
+
.string()
|
|
208
|
+
.optional()
|
|
209
|
+
.default("review")
|
|
210
|
+
.describe('Optional docs-relative subdirectory to write the page under (default "review"). No absolute paths, backslashes, ".." segments, null bytes, or encoded path tokens.'),
|
|
211
|
+
output_filename: z
|
|
212
|
+
.string()
|
|
213
|
+
.optional()
|
|
214
|
+
.describe('Optional output filename (default "${ticket_key}-decisions.html"). Must end with .html; no path separators.'),
|
|
215
|
+
labels: DecisionPageLabelsSchema.optional().describe("Optional presentation-label overrides (title, intro, section_heading, improvements_heading)."),
|
|
216
|
+
content: z
|
|
217
|
+
.record(z.string(), z.unknown())
|
|
218
|
+
.optional()
|
|
219
|
+
.describe("Contains deferred heavy payloads like actionable_items or system_goals."),
|
|
220
|
+
};
|
package/build/doctor.js
CHANGED
|
@@ -15,8 +15,11 @@
|
|
|
15
15
|
* (`which`/`where`, `bash --version`, `git rev-parse`) through the injected deps.
|
|
16
16
|
*/
|
|
17
17
|
import { readFile, stat } from "fs/promises";
|
|
18
|
+
import { spawn } from "child_process";
|
|
18
19
|
import os from "os";
|
|
20
|
+
import path from "path";
|
|
19
21
|
import { createDefaultStartTicketsDeps } from "./start-tickets.js";
|
|
22
|
+
import { VERSION } from "./version.generated.js";
|
|
20
23
|
import { DEFAULT_AGENT_NAME, resolveAgentSpec, isAgentName, formatValidAgentNames, } from "./agent-registry.js";
|
|
21
24
|
import { getDoctorPrereqDescriptors, probePrerequisite, } from "./start-tickets-prereqs.js";
|
|
22
25
|
/** User-facing usage text for the read-only `doctor` subcommand. */
|
|
@@ -161,6 +164,188 @@ export function formatDoctorReport(platform, agent, collection) {
|
|
|
161
164
|
: "All required prerequisites are present.");
|
|
162
165
|
return lines.join("\n");
|
|
163
166
|
}
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Launcher-cache diagnostics (BAPI-451 W3, E-4/E-9) — strictly read-only.
|
|
169
|
+
//
|
|
170
|
+
// install-bridge writes a launcher pinned to `@${VERSION}` (a specific `_npx`
|
|
171
|
+
// bucket). The FIRST launch of a not-yet-cached pin pays a cold install inline,
|
|
172
|
+
// which can exceed an MCP client's connect deadline. doctor inspects each
|
|
173
|
+
// project-local config's actual `bridge-api` launcher spec and reports:
|
|
174
|
+
// - `unpinned` — `@latest` / no version suffix (cache bucket is unstable).
|
|
175
|
+
// - `stale-pinned` — pinned to a version other than the running `VERSION`.
|
|
176
|
+
// - `warmed` — pinned to `VERSION` and the `_npx` bucket is present.
|
|
177
|
+
// - `indeterminate`— pinned to `VERSION` but the bucket probe was not a clean
|
|
178
|
+
// hit (a.k.a. `pinned-but-unwarmed`), or the config/spec was
|
|
179
|
+
// ambiguous. Never silently reported as "warm".
|
|
180
|
+
// The bucket probe is `npx --no-install <spec> --version`: `--no-install` means
|
|
181
|
+
// the probe can never mutate (warm) the cache, honoring the read-only contract.
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
/** The npm package whose pinned launcher spec doctor inspects. */
|
|
184
|
+
const BRIDGE_PACKAGE_NAME = "@bridge_gpt/mcp-server";
|
|
185
|
+
/** Project-local MCP configs doctor inspects (mirrors install-bridge's targets). */
|
|
186
|
+
const LAUNCHER_CONFIG_TARGETS = [
|
|
187
|
+
{ relPath: ".mcp.json", topLevelKey: "mcpServers" },
|
|
188
|
+
{ relPath: ".cursor/mcp.json", topLevelKey: "mcpServers" },
|
|
189
|
+
{ relPath: ".vscode/mcp.json", topLevelKey: "servers" },
|
|
190
|
+
];
|
|
191
|
+
/**
|
|
192
|
+
* Extract the `@bridge_gpt/mcp-server[@version]` launcher spec from an args array.
|
|
193
|
+
* The package name is itself scoped (`@scope/name`), so the version delimiter is
|
|
194
|
+
* the LAST `@`: `@bridge_gpt/mcp-server` is unpinned (no version), while
|
|
195
|
+
* `@bridge_gpt/mcp-server@0.2.11` pins `0.2.11`. Returns null when no spec matches.
|
|
196
|
+
*/
|
|
197
|
+
export function parseLauncherPin(args) {
|
|
198
|
+
if (!Array.isArray(args))
|
|
199
|
+
return null;
|
|
200
|
+
for (const arg of args) {
|
|
201
|
+
if (typeof arg !== "string")
|
|
202
|
+
continue;
|
|
203
|
+
if (arg === BRIDGE_PACKAGE_NAME)
|
|
204
|
+
return { spec: arg, version: null };
|
|
205
|
+
if (arg.startsWith(`${BRIDGE_PACKAGE_NAME}@`)) {
|
|
206
|
+
const version = arg.slice(BRIDGE_PACKAGE_NAME.length + 1).trim();
|
|
207
|
+
return { spec: arg, version: version.length > 0 ? version : null };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Default read-only cache probe. Spawns `npx --no-install <spec> --version`
|
|
214
|
+
* array-based (`shell: false`), ignores child stdio, and bounds the run with a
|
|
215
|
+
* timeout. A clean exit-0 means the pinned bucket is warm; any non-zero exit,
|
|
216
|
+
* spawn error, or timeout is classified `indeterminate` (never silently "warm").
|
|
217
|
+
* `--no-install` guarantees the probe never downloads/warms the bucket.
|
|
218
|
+
*/
|
|
219
|
+
function probeNpxNoInstallDefault(spec) {
|
|
220
|
+
return new Promise((resolve) => {
|
|
221
|
+
try {
|
|
222
|
+
const child = spawn("npx", ["--no-install", spec, "--version"], {
|
|
223
|
+
shell: false,
|
|
224
|
+
stdio: "ignore",
|
|
225
|
+
timeout: 30_000,
|
|
226
|
+
});
|
|
227
|
+
child.on("error", () => resolve({ warmed: false, indeterminate: true }));
|
|
228
|
+
child.on("close", (code, signal) => {
|
|
229
|
+
if (signal)
|
|
230
|
+
resolve({ warmed: false, indeterminate: true });
|
|
231
|
+
else if (code === 0)
|
|
232
|
+
resolve({ warmed: true, indeterminate: false });
|
|
233
|
+
else
|
|
234
|
+
resolve({ warmed: false, indeterminate: true });
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
resolve({ warmed: false, indeterminate: true });
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Inspect every project-local MCP config's `bridge-api` launcher pin (read-only).
|
|
244
|
+
* Configs that are absent, unparseable, or carry no `bridge-api` entry are skipped
|
|
245
|
+
* (an absent config is not a finding). Each present launcher is classified and,
|
|
246
|
+
* when pinned to the running `VERSION`, probed read-only with `--no-install`.
|
|
247
|
+
*/
|
|
248
|
+
export async function inspectLauncherCache(deps) {
|
|
249
|
+
const inspections = [];
|
|
250
|
+
for (const { relPath, topLevelKey } of LAUNCHER_CONFIG_TARGETS) {
|
|
251
|
+
const fullPath = path.join(deps.cwd, relPath);
|
|
252
|
+
let raw;
|
|
253
|
+
try {
|
|
254
|
+
raw = await deps.readFile(fullPath);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
continue; // config absent — not a finding.
|
|
258
|
+
}
|
|
259
|
+
let parsed;
|
|
260
|
+
try {
|
|
261
|
+
parsed = JSON.parse(raw);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
inspections.push({
|
|
265
|
+
relPath,
|
|
266
|
+
spec: null,
|
|
267
|
+
pinnedVersion: null,
|
|
268
|
+
state: "indeterminate",
|
|
269
|
+
remediation: "config is not valid JSON — cannot determine the launcher pin.",
|
|
270
|
+
});
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const entry = parsed && typeof parsed === "object"
|
|
274
|
+
? parsed[topLevelKey]?.["bridge-api"]
|
|
275
|
+
: undefined;
|
|
276
|
+
if (!entry)
|
|
277
|
+
continue; // no bridge-api launcher in this config — not a finding.
|
|
278
|
+
const pin = parseLauncherPin(entry.args);
|
|
279
|
+
if (!pin) {
|
|
280
|
+
inspections.push({
|
|
281
|
+
relPath,
|
|
282
|
+
spec: null,
|
|
283
|
+
pinnedVersion: null,
|
|
284
|
+
state: "indeterminate",
|
|
285
|
+
remediation: `no ${BRIDGE_PACKAGE_NAME} spec found in the launcher args.`,
|
|
286
|
+
});
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (pin.version === null || pin.version === "latest") {
|
|
290
|
+
inspections.push({
|
|
291
|
+
relPath,
|
|
292
|
+
spec: pin.spec,
|
|
293
|
+
pinnedVersion: pin.version,
|
|
294
|
+
state: "unpinned",
|
|
295
|
+
remediation: `pin the launcher to ${BRIDGE_PACKAGE_NAME}@${VERSION} (run /install-bridge or the install-bridge subcommand to rewrite this config).`,
|
|
296
|
+
});
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (pin.version !== VERSION) {
|
|
300
|
+
inspections.push({
|
|
301
|
+
relPath,
|
|
302
|
+
spec: pin.spec,
|
|
303
|
+
pinnedVersion: pin.version,
|
|
304
|
+
state: "stale-pinned",
|
|
305
|
+
remediation: `config pins ${pin.version} but this package is ${VERSION}; run upgrade-bridge / install-bridge to repin and re-warm.`,
|
|
306
|
+
});
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
// Pinned to the running VERSION — probe the bucket read-only.
|
|
310
|
+
const probe = await deps.probeNpxNoInstall(pin.spec);
|
|
311
|
+
if (probe.warmed) {
|
|
312
|
+
inspections.push({ relPath, spec: pin.spec, pinnedVersion: pin.version, state: "warmed" });
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
inspections.push({
|
|
316
|
+
relPath,
|
|
317
|
+
spec: pin.spec,
|
|
318
|
+
pinnedVersion: pin.version,
|
|
319
|
+
state: "indeterminate",
|
|
320
|
+
remediation: "pinned-but-unwarmed: the pinned _npx bucket is not a confirmed cache hit, so the first MCP launch may pay a one-time cold install. " +
|
|
321
|
+
`Warm it with: npx ${pin.spec} --version, or raise MCP_TIMEOUT for the first launch.`,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return inspections;
|
|
326
|
+
}
|
|
327
|
+
/** Render the launcher-cache section of the doctor report (pure formatting). */
|
|
328
|
+
export function formatLauncherCacheReport(inspections) {
|
|
329
|
+
const lines = ["", "Launcher cache (MCP cold-start readiness)", ""];
|
|
330
|
+
if (inspections.length === 0) {
|
|
331
|
+
lines.push("No project-local bridge-api launcher configs found to inspect.");
|
|
332
|
+
return lines.join("\n");
|
|
333
|
+
}
|
|
334
|
+
const labels = {
|
|
335
|
+
warmed: "WARMED ",
|
|
336
|
+
unpinned: "UNPINNED",
|
|
337
|
+
"stale-pinned": "STALE-PINNED",
|
|
338
|
+
indeterminate: "INDETERMINATE",
|
|
339
|
+
};
|
|
340
|
+
for (const i of inspections) {
|
|
341
|
+
const specText = i.spec ? ` (${i.spec})` : "";
|
|
342
|
+
lines.push(`${labels[i.state]} ${i.relPath}${specText}`);
|
|
343
|
+
if (i.remediation) {
|
|
344
|
+
lines.push(` ${i.remediation}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return lines.join("\n");
|
|
348
|
+
}
|
|
164
349
|
/**
|
|
165
350
|
* CLI entry for the read-only `doctor` subcommand. Returns a process exit code.
|
|
166
351
|
* Help returns 0; parser errors return 1; otherwise it prints the report and
|
|
@@ -186,6 +371,21 @@ export async function runDoctorCli(argv, overrides = {}) {
|
|
|
186
371
|
const agent = resolveAgentSpec(parsed.options.agentName) ?? resolveAgentSpec(DEFAULT_AGENT_NAME);
|
|
187
372
|
const collection = await collectDoctorResults(deps, parsed.options.agentName);
|
|
188
373
|
log(formatDoctorReport(deps.platform, agent, collection));
|
|
374
|
+
// Strictly read-only launcher-cache diagnostics (BAPI-451). Best-effort: a probe
|
|
375
|
+
// failure never changes the doctor exit code (cold-start readiness is advisory,
|
|
376
|
+
// not a hard prerequisite). The exit code remains driven by required prereqs.
|
|
377
|
+
try {
|
|
378
|
+
const launcherDeps = {
|
|
379
|
+
cwd: overrides.launcherProbe?.cwd ?? deps.cwd,
|
|
380
|
+
readFile: overrides.launcherProbe?.readFile ?? ((p) => readFile(p, "utf-8")),
|
|
381
|
+
probeNpxNoInstall: overrides.launcherProbe?.probeNpxNoInstall ?? probeNpxNoInstallDefault,
|
|
382
|
+
};
|
|
383
|
+
const launcherInspections = await inspectLauncherCache(launcherDeps);
|
|
384
|
+
log(formatLauncherCacheReport(launcherInspections));
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
/* launcher-cache diagnostics are advisory; never block the doctor report */
|
|
388
|
+
}
|
|
189
389
|
if (!collection.ok)
|
|
190
390
|
return 1;
|
|
191
391
|
return collection.results.some((r) => !r.found) ? 1 : 0;
|