@bridge_gpt/mcp-server 0.2.36 → 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.
- package/README.md +44 -6
- package/build/base-url.js +79 -0
- package/build/bridge-api-urls.js +9 -0
- package/build/chain-orchestrator.js +93 -15
- package/build/claude-user-config-doctor.js +317 -0
- package/build/commands.generated.js +2 -1
- package/build/conductor/bridge-api-client.js +178 -4
- package/build/conductor-bin.js +1 -1
- package/build/conductor-bundle-artifacts.js +7 -6
- package/build/credential-store.js +205 -4
- package/build/direct-ticket-tools.js +70 -0
- package/build/doctor.js +239 -80
- package/build/executor/cli.js +51 -1
- package/build/executor/credentials.js +1 -7
- package/build/executor/deps.js +18 -1
- package/build/executor/env.js +51 -25
- package/build/executor/heartbeat.js +138 -17
- package/build/executor/http-client.js +49 -8
- package/build/executor/job-errors.js +4 -0
- package/build/executor/job-runner.js +422 -22
- package/build/executor/observation.js +130 -0
- package/build/executor/permissions.js +104 -8
- package/build/executor/preflight.js +32 -0
- package/build/executor/runner.js +8 -0
- package/build/executor/test-clock.js +67 -3
- package/build/executor/types.js +4 -1
- package/build/executor/worker-command.js +11 -3
- package/build/executor/worker-config-isolation.js +287 -0
- package/build/executor/worker-finalization.js +68 -14
- package/build/executor/worktree.js +46 -4
- package/build/index.js +614 -244
- package/build/init.js +363 -73
- package/build/install-bridge.js +568 -80
- package/build/launcher-config-inspection.js +351 -0
- package/build/mcp-invoke.js +49 -6
- package/build/mcp-provisioning.js +30 -7
- package/build/mcp-registration-doctor.js +14 -5
- package/build/notifications.js +553 -0
- package/build/pipeline-orchestrator.js +146 -4
- package/build/pipeline-utils.js +3 -0
- package/build/pipelines.generated.js +22 -9
- package/build/plan-execution-ledger.js +550 -0
- package/build/plan-phase-routing.js +272 -0
- package/build/plane/alembic-head.js +110 -0
- package/build/plane/build-freshness.js +167 -0
- package/build/plane/cli.js +480 -0
- package/build/plane/defaults.js +266 -0
- package/build/plane/manifest.js +377 -0
- package/build/plane/member-logs.js +147 -0
- package/build/plane/member-roster.js +147 -0
- package/build/plane/preflight.js +289 -0
- package/build/plane/shutdown.js +195 -0
- package/build/plane/status.js +125 -0
- package/build/plane/supervisor.js +569 -0
- package/build/plane/test-fakes.js +156 -0
- package/build/plane/types.js +75 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +2 -0
- package/build/setup-epic.js +662 -27
- package/build/sfcc/log-gate.js +38 -11
- package/build/sfcc/log-query.js +55 -15
- package/build/sfcc/ocapi-shape.js +51 -14
- package/build/sfcc/output.js +41 -11
- package/build/sfcc/permissions.js +24 -2
- package/build/sfcc/read-projection.js +181 -0
- package/build/sfcc/read-result.js +158 -0
- package/build/sfcc/reads-custom-object-def.js +29 -18
- package/build/sfcc/reads-site-preference.js +75 -29
- package/build/sfcc/reads-system-object.js +40 -34
- package/build/sfcc/sfcc-result.js +106 -0
- package/build/sfcc/tool-wrapper.js +56 -13
- package/build/sfcc/write-grants.js +45 -22
- package/build/sfcc/write-guard.js +21 -13
- package/build/sfcc/write-result.js +61 -14
- package/build/sfcc/write-tool-common.js +126 -32
- package/build/sfcc/writes-system-object.js +11 -50
- package/build/start-tickets-prereqs.js +129 -0
- package/build/start-tickets.js +17 -13
- package/build/ticket-backend-metadata.js +59 -0
- package/build/ticket-key-utils.js +92 -0
- package/build/tool-error-envelope.js +71 -0
- package/build/tool-surface-gating.js +72 -0
- package/build/update-status.js +102 -0
- package/build/upgrade-advice.js +47 -0
- package/build/upgrade-cli.js +417 -101
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +73 -0
- package/docs/CONDUCTOR.md +23 -8
- package/package.json +3 -3
- package/pipelines/implement-ticket.json +15 -5
package/README.md
CHANGED
|
@@ -397,7 +397,7 @@ An entry with no trailing `serve` still starts the server — bare invocation me
|
|
|
397
397
|
<summary><strong>Upgrading Bridge</strong></summary>
|
|
398
398
|
|
|
399
399
|
```bash
|
|
400
|
-
npx -y @bridge_gpt/mcp-server upgrade
|
|
400
|
+
npx -y @bridge_gpt/mcp-server@latest --upgrade
|
|
401
401
|
```
|
|
402
402
|
|
|
403
403
|
`upgrade` fetches the latest published version, refreshes your scaffolded slash
|
|
@@ -405,8 +405,34 @@ commands, agents, and pipelines, updates the version pin in your MCP config, and
|
|
|
405
405
|
opens a session so you can reconnect. It is also available as the
|
|
406
406
|
`/upgrade-bridge` slash command.
|
|
407
407
|
|
|
408
|
-
|
|
409
|
-
|
|
408
|
+
Use the `@latest` form. It applies to the short-lived *upgrader* process: without
|
|
409
|
+
it, npx may reuse a cached older copy of the package and "upgrade" you with the
|
|
410
|
+
build you are trying to replace. The exact `MAJOR.MINOR.PATCH` pin the upgrader
|
|
411
|
+
writes into your MCP config is deliberately different — host configs stay pinned
|
|
412
|
+
to an exact release so a project's server is reproducible.
|
|
413
|
+
|
|
414
|
+
`upgrade` reports **per config file**, because a project can have several
|
|
415
|
+
(`.mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json`) and they can disagree:
|
|
416
|
+
|
|
417
|
+
```
|
|
418
|
+
Launcher pins:
|
|
419
|
+
.mcp.json: 0.2.16 -> 0.2.36
|
|
420
|
+
.cursor/mcp.json: already 0.2.36
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
When every applicable launcher pin was already at the target, it prints
|
|
424
|
+
`Already up-to-date.` — that status comes from comparing your configs, not from
|
|
425
|
+
the version of the CLI process. A non-zero exit means the upgrade did **not**
|
|
426
|
+
converge (an unreadable config, a launcher carrying a version range the upgrader
|
|
427
|
+
must not rewrite, a competing local install it could not remove, or a pin that
|
|
428
|
+
failed post-write verification); nothing is reported as complete in that case.
|
|
429
|
+
|
|
430
|
+
The server checks for updates on startup. The check is cached for a day and never
|
|
431
|
+
blocks startup. When a newer version is known, it surfaces in two places you do
|
|
432
|
+
not have to go looking for: a one-line warning on the server's **stderr**, and a
|
|
433
|
+
short advisory attached to the ordinary `tools/list` response so the agent in the
|
|
434
|
+
session can see that some tools may be missing or renamed in the older build.
|
|
435
|
+
Neither requires calling `ping` or `doctor`.
|
|
410
436
|
|
|
411
437
|
Re-running `install` on an already-configured project is safe: it refreshes the
|
|
412
438
|
scaffolded files without overwriting your stored credential unless you pass
|
|
@@ -559,6 +585,12 @@ Good to know, but not needed every day.
|
|
|
559
585
|
- **How to use it:** Ask your agent to resume the full-automation chain, naming the run to continue.
|
|
560
586
|
- **Flags:** None.
|
|
561
587
|
|
|
588
|
+
**15. Update Ticket Description**
|
|
589
|
+
- **What it does:** Rewrites a ticket's description with AI, using the ticket's own content and its reference material. A rewrite that changes more than 60% of the description is held for review instead of applied.
|
|
590
|
+
- **When it's useful:** (Refinement) When a ticket has accumulated comments, attachments, or links and its description no longer reflects them.
|
|
591
|
+
- **How to use it:** Ask your agent — "Update the description for BAPI-123."
|
|
592
|
+
- **Flags:** None. Poll the ticket's state for the outcome; if the update was held for review, read the proposal instead of applying it blind.
|
|
593
|
+
|
|
562
594
|
### Now and then
|
|
563
595
|
|
|
564
596
|
Useful once in a while.
|
|
@@ -611,7 +643,13 @@ Useful once in a while.
|
|
|
611
643
|
- **How to use it:** Ask your agent — "Download the design mockups attached to BAPI-123 into my docs folder," or "Attach build-log.txt to BAPI-123."
|
|
612
644
|
- **Flags:** Choose the direction (download from the ticket, or upload to it) and, for a download, where the files should land.
|
|
613
645
|
|
|
614
|
-
**9. Estimate
|
|
646
|
+
**9. Estimate Ticket**
|
|
647
|
+
- **What it does:** Estimates the development effort for one ticket. Use Estimate Epic instead for a whole epic or a named group of tickets.
|
|
648
|
+
- **When it's useful:** (Refinement) When you need a size for a single ticket before committing to it.
|
|
649
|
+
- **How to use it:** Ask your agent — "Estimate BAPI-123."
|
|
650
|
+
- **Flags:** Ask for a fresh estimate to regenerate rather than reuse a stored one.
|
|
651
|
+
|
|
652
|
+
**10. Estimate Epic**
|
|
615
653
|
- **What it does:** Estimates an epic, or an explicit group of tickets you name.
|
|
616
654
|
- **When it's useful:** (Architecture | Refinement) When you need a sizing pass across an epic, or across a set of tickets you name explicitly.
|
|
617
655
|
- **How to use it:** `/estimate-epic BAPI-123`
|
|
@@ -756,7 +794,7 @@ Without this, only the diagnostic tools are registered.
|
|
|
756
794
|
|
|
757
795
|
### Tools
|
|
758
796
|
|
|
759
|
-
All SFCC tools are read-only and target a developer sandbox.
|
|
797
|
+
All SFCC tools are read-only and target a developer sandbox. An oversized response is saved in full to `BAPI_DOCS_DIR/sfcc/` and replaced by a parseable JSON descriptor — `truncated: true`, the `saved_path` it was written to, and the `page` metadata (`returned`, `total` when OCAPI supplied one, `has_more`) — so the collection metadata survives even though the data itself is on disk. If that save fails, the complete payload is returned inline instead, still as parseable JSON.
|
|
760
798
|
|
|
761
799
|
**Diagnostics** (always available, no profile needed)
|
|
762
800
|
- `sfcc_setup_status` — report on every prerequisite: Bridge API key, repo name, `version` config, `dw.json` presence/uniqueness, AM (OCAPI) token acquisition, and the independent **SFCC Log Query (WebDAV)** capability that gates `sfcc_log_query`.
|
|
@@ -764,7 +802,7 @@ All SFCC tools are read-only and target a developer sandbox. Oversized responses
|
|
|
764
802
|
|
|
765
803
|
**System object model** (needs the `sfcc` profile)
|
|
766
804
|
- `system_object_list` — list system object types (Product, Order, Customer, …).
|
|
767
|
-
- `system_object_get` — fetch one type's definition
|
|
805
|
+
- `system_object_get` — fetch one system object type's definition.
|
|
768
806
|
- `system_object_attribute_search` — search a type's attribute definitions; prefer this over a full dump when hunting a specific `c_` custom attribute.
|
|
769
807
|
|
|
770
808
|
**Custom object definitions** (needs the `sfcc` profile)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure Bridge API base-URL normalization and validation (BAPI-727).
|
|
3
|
+
*
|
|
4
|
+
* Extracted so the executor's credential resolution and the `mcp-invoke` shim can
|
|
5
|
+
* share ONE definition of "normalized base URL" without `mcp-invoke` importing
|
|
6
|
+
* executor runtime behavior (`executor/credentials.ts` pulls in the executor deps
|
|
7
|
+
* types and the credential store, neither of which the shim should depend on).
|
|
8
|
+
*
|
|
9
|
+
* Everything here is pure: no filesystem, no process, no network, no environment.
|
|
10
|
+
* Validation messages name the flag and the reason ONLY — never the supplied URL,
|
|
11
|
+
* which can carry embedded credentials in its userinfo component.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Trim, strip every trailing slash, and treat an empty result as absent.
|
|
15
|
+
*
|
|
16
|
+
* This is the executor's long-standing normalization, moved verbatim so the value
|
|
17
|
+
* a worker registration carries is byte-identical to the one the executor itself
|
|
18
|
+
* uses for its HTTP calls.
|
|
19
|
+
*/
|
|
20
|
+
export function normalizeBaseUrlCandidate(raw) {
|
|
21
|
+
if (typeof raw !== "string")
|
|
22
|
+
return null;
|
|
23
|
+
const normalized = raw.trim().replace(/\/+$/, "");
|
|
24
|
+
return normalized.length > 0 ? normalized : null;
|
|
25
|
+
}
|
|
26
|
+
/** Fixed, secret-free explanation for each rejection reason. */
|
|
27
|
+
const REJECTION_TEXT = {
|
|
28
|
+
empty: "value is empty",
|
|
29
|
+
"not-absolute-http-url": "value is not an absolute URL",
|
|
30
|
+
"unsupported-scheme": "only http: and https: URLs are supported",
|
|
31
|
+
"missing-hostname": "URL has no hostname",
|
|
32
|
+
"embedded-credentials": "URL must not embed a username or password",
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Human-readable rejection message for a flag. Interpolates only the flag name and
|
|
36
|
+
* a fixed reason — deliberately NOT the rejected value, so a URL containing
|
|
37
|
+
* `https://user:secret@host` can never reach an error string, usage text, or log.
|
|
38
|
+
*/
|
|
39
|
+
export function describeBaseUrlRejection(flag, reason) {
|
|
40
|
+
return `Invalid ${flag}: ${REJECTION_TEXT[reason]}`;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Strictly validate a base URL supplied on a command line: it must be a non-empty,
|
|
44
|
+
* absolute `http:`/`https:` URL with a hostname and no embedded user information.
|
|
45
|
+
* The accepted value is returned normalized by
|
|
46
|
+
* {@link normalizeBaseUrlCandidate} so it matches the executor's own form.
|
|
47
|
+
*/
|
|
48
|
+
export function validateHttpBaseUrl(raw) {
|
|
49
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
50
|
+
return { ok: false, reason: "empty" };
|
|
51
|
+
}
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = new URL(raw.trim());
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// A relative value ("/api", "api.example.test/x") lands here: the WHATWG URL
|
|
58
|
+
// constructor requires an absolute URL when no base is supplied.
|
|
59
|
+
return { ok: false, reason: "not-absolute-http-url" };
|
|
60
|
+
}
|
|
61
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
62
|
+
return { ok: false, reason: "unsupported-scheme" };
|
|
63
|
+
}
|
|
64
|
+
// Defensive: for http/https the WHATWG parser already rejects an empty
|
|
65
|
+
// authority above (`http://` throws), so this branch is not reachable through
|
|
66
|
+
// those schemes today. It is kept because "must have a hostname" is the real
|
|
67
|
+
// invariant, and relying on a parser implementation detail to enforce it would
|
|
68
|
+
// be fragile.
|
|
69
|
+
if (parsed.hostname.length === 0) {
|
|
70
|
+
return { ok: false, reason: "missing-hostname" };
|
|
71
|
+
}
|
|
72
|
+
if (parsed.username.length > 0 || parsed.password.length > 0) {
|
|
73
|
+
return { ok: false, reason: "embedded-credentials" };
|
|
74
|
+
}
|
|
75
|
+
const normalized = normalizeBaseUrlCandidate(raw);
|
|
76
|
+
if (normalized === null)
|
|
77
|
+
return { ok: false, reason: "empty" };
|
|
78
|
+
return { ok: true, baseUrl: normalized };
|
|
79
|
+
}
|
package/build/bridge-api-urls.js
CHANGED
|
@@ -9,6 +9,15 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The base URL is supplied by the caller (never hardcoded here) and its
|
|
11
11
|
* trailing slashes are trimmed so no doubled slash is produced.
|
|
12
|
+
*
|
|
13
|
+
* The `/jira` segment is the Bridge API's TRANSPORT namespace — the router mount
|
|
14
|
+
* that serves these routes in BOTH ticket backend modes. It is not, and must not
|
|
15
|
+
* become, an agent-visible ticket link: a ticket's URL is whatever the server
|
|
16
|
+
* returns on the create/read response (`https://…` in Jira mode,
|
|
17
|
+
* `local://tickets/…` in local mode) and is passed through verbatim. Renaming
|
|
18
|
+
* this mount is explicitly out of scope (no renaming of Jira-shaped surfaces),
|
|
19
|
+
* and synthesizing a ticket URL from it would produce a link to an API route
|
|
20
|
+
* rather than to a ticket.
|
|
12
21
|
*/
|
|
13
22
|
/**
|
|
14
23
|
* Create the URL builders bound to `baseUrl`. Trailing slashes on the base are
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import { runPipeline, resumePipeline, peekPipelineRun } from "./pipeline-orchestrator.js";
|
|
27
27
|
import { toStringVariable, substituteChainValue } from "./chain-utils.js";
|
|
28
|
+
import { isValidTicketKey, validateTicketKeys } from "./ticket-key-utils.js";
|
|
29
|
+
import { isUnsupportedInLocalModeEnvelope, parseToolErrorEnvelope, } from "./tool-error-envelope.js";
|
|
28
30
|
const CHAIN_NAME = "full-automation";
|
|
29
31
|
const START_TICKETS_PIPELINE = "start-tickets";
|
|
30
32
|
// Display titles for preambles, keyed by child pipeline name.
|
|
@@ -166,13 +168,22 @@ function normalizeAutoApprove(value) {
|
|
|
166
168
|
return true;
|
|
167
169
|
return false;
|
|
168
170
|
}
|
|
169
|
-
const JIRA_KEY_EXACT = /^[A-Z][A-Z0-9]+-\d+$/;
|
|
170
171
|
/**
|
|
171
|
-
* Extract created
|
|
172
|
+
* Extract created ticket keys from a STRUCTURED payload (an object/array
|
|
172
173
|
* holding `created_ticket_keys` / `ticket_key` / etc.). Collects values from the
|
|
173
|
-
* preferred key fields in a fixed preference order, validates each against
|
|
174
|
-
* `
|
|
175
|
-
*
|
|
174
|
+
* preferred key fields in a fixed preference order, validates each against the
|
|
175
|
+
* canonical `TICKET_KEY_PATTERN`, and de-dupes in encounter order.
|
|
176
|
+
*
|
|
177
|
+
* BAPI-710: the validation used to run against a private `/^[A-Z][A-Z0-9]+-\d+$/`
|
|
178
|
+
* that rejected keys the server mints (`MY_PROJ-248`, `A-1`), and this helper
|
|
179
|
+
* DROPPED every non-match silently. Silent dropping is now impossible for the
|
|
180
|
+
* chain — {@link extractCreatedTicketPayload} rejects the whole payload and names
|
|
181
|
+
* every offending key. This flat helper still filters (its `string[]` contract
|
|
182
|
+
* has no failure channel), but it is a non-chain compatibility path only and the
|
|
183
|
+
* grammar it filters on is now the correct one.
|
|
184
|
+
*
|
|
185
|
+
* There is deliberately NO free-text regex fallback: pass the parsed
|
|
186
|
+
* upload-step payload (via
|
|
176
187
|
* `extractCreatedKeysFromCompletedChild`), never a whole pipeline transcript, so
|
|
177
188
|
* keys merely *mentioned* by an earlier step (e.g. duplicate-detection search
|
|
178
189
|
* hits) can never be mistaken for this run's creations. A string / no-field
|
|
@@ -226,7 +237,7 @@ export function extractCreatedTicketKeys(source) {
|
|
|
226
237
|
const ordered = [];
|
|
227
238
|
for (const key of preferredKeys) {
|
|
228
239
|
for (const candidate of buckets[key]) {
|
|
229
|
-
if (
|
|
240
|
+
if (isValidTicketKey(candidate))
|
|
230
241
|
ordered.push(candidate);
|
|
231
242
|
}
|
|
232
243
|
}
|
|
@@ -278,7 +289,7 @@ function tryParseJson(raw) {
|
|
|
278
289
|
}
|
|
279
290
|
}
|
|
280
291
|
function isValidJiraKey(value) {
|
|
281
|
-
return
|
|
292
|
+
return isValidTicketKey(value);
|
|
282
293
|
}
|
|
283
294
|
/**
|
|
284
295
|
* Normalize a PARSED, STRUCTURED upload payload into a {@link CreatedTicketPayload}.
|
|
@@ -316,7 +327,7 @@ export function extractCreatedTicketPayload(source) {
|
|
|
316
327
|
if (!isValidJiraKey(parent)) {
|
|
317
328
|
return {
|
|
318
329
|
ok: false,
|
|
319
|
-
error: `Epic payload "epic_parent_key" must be a valid
|
|
330
|
+
error: `Epic payload "epic_parent_key" must be a valid ticket key; got ${JSON.stringify(parent)}.`,
|
|
320
331
|
};
|
|
321
332
|
}
|
|
322
333
|
const rawChildren = obj.child_ticket_keys;
|
|
@@ -327,14 +338,20 @@ export function extractCreatedTicketPayload(source) {
|
|
|
327
338
|
"implementable child ticket key.",
|
|
328
339
|
};
|
|
329
340
|
}
|
|
341
|
+
// Validate the WHOLE child list first and report every rejected key in one
|
|
342
|
+
// error (BAPI-710). Failing on the first bad key made a caller fix its
|
|
343
|
+
// payload one key per round trip, and the previous grammar rejected keys the
|
|
344
|
+
// server legitimately mints — so a mixed-validity list must fail atomically,
|
|
345
|
+
// never advance with the valid subset.
|
|
346
|
+
const childKeyCheck = validateTicketKeys(rawChildren, "child_ticket_keys entry");
|
|
347
|
+
if (!childKeyCheck.ok) {
|
|
348
|
+
return {
|
|
349
|
+
ok: false,
|
|
350
|
+
error: `Epic payload "child_ticket_keys" must contain only valid implementable ticket keys. ${childKeyCheck.message}`,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
330
353
|
const children = [];
|
|
331
354
|
for (const c of rawChildren) {
|
|
332
|
-
if (!isValidJiraKey(c)) {
|
|
333
|
-
return {
|
|
334
|
-
ok: false,
|
|
335
|
-
error: `Epic payload "child_ticket_keys" must contain only valid implementable Jira keys; got ${JSON.stringify(c)}.`,
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
355
|
if (c === parent) {
|
|
339
356
|
return {
|
|
340
357
|
ok: false,
|
|
@@ -361,7 +378,7 @@ export function extractCreatedTicketPayload(source) {
|
|
|
361
378
|
return {
|
|
362
379
|
ok: false,
|
|
363
380
|
error: 'Single-ticket payload "created_ticket_keys" must contain exactly ' +
|
|
364
|
-
"one valid implementable
|
|
381
|
+
"one valid implementable ticket key. A multi-key list is malformed " +
|
|
365
382
|
'(the legacy "first key is the Epic parent" convention is not accepted).',
|
|
366
383
|
};
|
|
367
384
|
}
|
|
@@ -431,6 +448,36 @@ function failedEnvelopeMentions(env, token) {
|
|
|
431
448
|
}
|
|
432
449
|
return false;
|
|
433
450
|
}
|
|
451
|
+
/**
|
|
452
|
+
* Find a recognized ``UNSUPPORTED_IN_LOCAL_MODE`` refusal anywhere in a child
|
|
453
|
+
* pipeline envelope (BAPI-710).
|
|
454
|
+
*
|
|
455
|
+
* Scans the top-level halt error and each step's ``error`` / string ``result``,
|
|
456
|
+
* matching only the exact ``(error, status)`` pair via the strict parser — never
|
|
457
|
+
* a substring of prose, so an echoed input or a message that merely mentions the
|
|
458
|
+
* marker cannot terminate a chain.
|
|
459
|
+
*
|
|
460
|
+
* A ``completed`` envelope is scanned too, not just a ``failed`` one: a
|
|
461
|
+
* ``warn_and_continue`` step in an older persisted run can have swallowed the
|
|
462
|
+
* refusal into a completed envelope, and treating that as success is exactly the
|
|
463
|
+
* partial-success outcome this check exists to prevent.
|
|
464
|
+
*/
|
|
465
|
+
function findUnsupportedLocalModeRefusal(env) {
|
|
466
|
+
const topLevel = parseToolErrorEnvelope(env.error);
|
|
467
|
+
if (isUnsupportedInLocalModeEnvelope(topLevel))
|
|
468
|
+
return topLevel;
|
|
469
|
+
const results = env.results;
|
|
470
|
+
if (!Array.isArray(results))
|
|
471
|
+
return null;
|
|
472
|
+
for (const step of results) {
|
|
473
|
+
for (const candidate of [step?.error, step?.result]) {
|
|
474
|
+
const parsed = parseToolErrorEnvelope(candidate);
|
|
475
|
+
if (isUnsupportedInLocalModeEnvelope(parsed))
|
|
476
|
+
return parsed;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
434
481
|
/**
|
|
435
482
|
* Heuristic, no-LLM one-line stage summary. idea-to-ticket passes a structured
|
|
436
483
|
* {@link CreatedTicketPayload} so the summary distinguishes the Epic parent from
|
|
@@ -782,6 +829,37 @@ async function handleChildPipelineEnvelope(persistence, recipe, row, childEnv, o
|
|
|
782
829
|
}),
|
|
783
830
|
};
|
|
784
831
|
}
|
|
832
|
+
// BAPI-710 terminal gate. Runs BEFORE the failed-mapping branch, before the
|
|
833
|
+
// fan-out advance, and before the single-child finalize — i.e. before review
|
|
834
|
+
// fan-out, worktree preparation, or any start-tickets command can be emitted.
|
|
835
|
+
// A backend that cannot perform the operation will not start being able to on
|
|
836
|
+
// the next stage, so continuing would only produce a partial success that
|
|
837
|
+
// looks like a real one.
|
|
838
|
+
const refusal = findUnsupportedLocalModeRefusal(childEnv);
|
|
839
|
+
if (refusal) {
|
|
840
|
+
const stages = cloneStages(row.stages);
|
|
841
|
+
if (stages[idx])
|
|
842
|
+
stages[idx].status = "failed";
|
|
843
|
+
try {
|
|
844
|
+
await persistence.patchRun(row.chain_run_id, { stages, status: "failed" });
|
|
845
|
+
}
|
|
846
|
+
catch {
|
|
847
|
+
// best-effort — the terminal failure is what matters
|
|
848
|
+
}
|
|
849
|
+
return {
|
|
850
|
+
kind: "fail",
|
|
851
|
+
envelope: {
|
|
852
|
+
...failedEnvelope("TOOL_ERROR", refusal.message, {
|
|
853
|
+
chain_run_id: row.chain_run_id,
|
|
854
|
+
chain_stage: stageRecipe.pipeline_name,
|
|
855
|
+
chain_step: idx + 1,
|
|
856
|
+
chain_total: total,
|
|
857
|
+
}),
|
|
858
|
+
backend_error: refusal.error,
|
|
859
|
+
...(refusal.supported_in ? { supported_in: refusal.supported_in } : {}),
|
|
860
|
+
},
|
|
861
|
+
};
|
|
862
|
+
}
|
|
785
863
|
if (childEnv.status === "failed") {
|
|
786
864
|
// Stage 0 vagueness or no-keys produced => VALIDATION. Anchor the sentinel
|
|
787
865
|
// check to the failure-text fields (the halt error + per-step error/result
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only inspection of the machine's Claude user configuration
|
|
3
|
+
* (`~/.claude.json`) for `bridge-api` MCP registrations that can SHADOW the
|
|
4
|
+
* secret-free registration the conductor provisions into a worker worktree
|
|
5
|
+
* (BAPI-727).
|
|
6
|
+
*
|
|
7
|
+
* EMPIRICALLY ESTABLISHED BEHAVIOR — the only precedence claim this module makes:
|
|
8
|
+
* a `projects["<main repository path>"].mcpServers["bridge-api"]` entry was
|
|
9
|
+
* observed to win over a linked worktree's own `.mcp.json`. That is the failure
|
|
10
|
+
* this inspector exists to detect: the worker silently talks to whatever server
|
|
11
|
+
* the user-level entry names (in practice, production) instead of the endpoint the
|
|
12
|
+
* executor provisioned.
|
|
13
|
+
*
|
|
14
|
+
* NOT ESTABLISHED, AND MUST NOT BE INFERRED FROM THIS MODULE:
|
|
15
|
+
* - user-scope (`mcpServers`) versus project-scope precedence,
|
|
16
|
+
* - precedence of a `projects["<worktree path>"]` entry against anything else,
|
|
17
|
+
* - symlink equivalence between a configured path and the real path,
|
|
18
|
+
* - filesystem case-equivalence on case-insensitive hosts.
|
|
19
|
+
* The inspector therefore looks in all three places and reports what it finds; it
|
|
20
|
+
* does not rank them.
|
|
21
|
+
*
|
|
22
|
+
* SEPARATE PRIOR ART: the BAPI-451 cold-start "failed to connect" condition is a
|
|
23
|
+
* launcher/npx-cache TIMING problem, not configuration shadowing. Nothing here
|
|
24
|
+
* diagnoses or remediates that.
|
|
25
|
+
*
|
|
26
|
+
* SECRET SAFETY: this module never retains the parsed document, a registration
|
|
27
|
+
* entry, a command/argument array, an environment VALUE, a read exception, or
|
|
28
|
+
* malformed source text. Findings carry only scope labels, the config path, a
|
|
29
|
+
* fixed shape classification, and sorted environment KEY NAMES.
|
|
30
|
+
*/
|
|
31
|
+
import { isBridgeApiShimEntry, readJsonIfPresent } from "./mcp-registration-doctor.js";
|
|
32
|
+
import { normalizeWorktreePathForRegistration, pathApiForProvisioningPlatform, } from "./mcp-provisioning.js";
|
|
33
|
+
/** The registration key both the provisioner and a user config would use. */
|
|
34
|
+
const BRIDGE_API_SERVER_NAME = "bridge-api";
|
|
35
|
+
/** The Claude user configuration file name, at the home directory root. */
|
|
36
|
+
const CLAUDE_USER_CONFIG_FILENAME = ".claude.json";
|
|
37
|
+
/**
|
|
38
|
+
* Build the platform-appropriate path to the Claude user configuration from an
|
|
39
|
+
* explicitly supplied home directory. Shared by executor preflight, per-worktree
|
|
40
|
+
* enforcement, and doctor so none of them hardcodes its own join, and consults no
|
|
41
|
+
* global process state.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveClaudeUserConfigPath(homeDirectory, platform) {
|
|
44
|
+
return pathApiForProvisioningPlatform(platform).join(homeDirectory, CLAUDE_USER_CONFIG_FILENAME);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Environment override that downgrades a confirmed shadowing collision from a
|
|
48
|
+
* refusal to a loud warning. Deliberately long and conductor-scoped so it cannot
|
|
49
|
+
* be set by accident.
|
|
50
|
+
*/
|
|
51
|
+
export const CONDUCTOR_ALLOW_CLAUDE_MCP_SHADOWING_ENV = "BAPI_CONDUCTOR_ALLOW_CLAUDE_MCP_SHADOWING";
|
|
52
|
+
/** A plain, non-array object. */
|
|
53
|
+
function asRecord(value) {
|
|
54
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
55
|
+
return null;
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Sorted `env` key names, or `[]` when the entry has no `env` object. Only NAMES
|
|
60
|
+
* are read; the values are never touched.
|
|
61
|
+
*/
|
|
62
|
+
function envKeyNamesOf(entry) {
|
|
63
|
+
const record = asRecord(entry);
|
|
64
|
+
if (!record)
|
|
65
|
+
return [];
|
|
66
|
+
const env = asRecord(record.env);
|
|
67
|
+
if (!env)
|
|
68
|
+
return [];
|
|
69
|
+
return Object.keys(env).sort();
|
|
70
|
+
}
|
|
71
|
+
/** True when the entry declares an `env` object at all (even an empty one). */
|
|
72
|
+
function hasEnvBlock(entry) {
|
|
73
|
+
const record = asRecord(entry);
|
|
74
|
+
if (!record)
|
|
75
|
+
return false;
|
|
76
|
+
return asRecord(record.env) !== null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Structural shape probe: does this look like the Bridge `mcp-invoke` shim,
|
|
80
|
+
* ignoring WHICH project root it names? Used only to tell "a shim aimed at
|
|
81
|
+
* another checkout" apart from "some unrelated command"; acceptance always goes
|
|
82
|
+
* through {@link isBridgeApiShimEntry} against an expected path.
|
|
83
|
+
*/
|
|
84
|
+
function looksLikeBridgeShimCommand(entry) {
|
|
85
|
+
const record = asRecord(entry);
|
|
86
|
+
if (!record)
|
|
87
|
+
return false;
|
|
88
|
+
if (typeof record.command !== "string" || record.command.length === 0)
|
|
89
|
+
return false;
|
|
90
|
+
if (!Array.isArray(record.args))
|
|
91
|
+
return false;
|
|
92
|
+
const args = record.args.filter((a) => typeof a === "string");
|
|
93
|
+
if (!args.includes("mcp-invoke"))
|
|
94
|
+
return false;
|
|
95
|
+
const targetIndex = args.indexOf("--target");
|
|
96
|
+
return targetIndex >= 0 && args[targetIndex + 1] === "bapi";
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Classify a registration for one scope.
|
|
100
|
+
*
|
|
101
|
+
* An entry is an acceptable provisioned shim only when `isBridgeApiShimEntry()`
|
|
102
|
+
* accepts it for one of the paths this scope may legitimately name AND it stays
|
|
103
|
+
* secret-free (no `env` block). The provisioner emits exactly `{ command, args }`
|
|
104
|
+
* — an `env` block therefore proves the entry was not written by it, no matter how
|
|
105
|
+
* closely the command and arguments otherwise resemble the shim.
|
|
106
|
+
*
|
|
107
|
+
* `allowedPaths` is empty when the caller could not resolve the path this scope
|
|
108
|
+
* compares against; a shim-shaped entry then classifies as inconclusive rather
|
|
109
|
+
* than as a confirmed collision, so an unresolved path can never manufacture a
|
|
110
|
+
* false refusal.
|
|
111
|
+
*/
|
|
112
|
+
function classifyEntry(entry, allowedPaths) {
|
|
113
|
+
if (hasEnvBlock(entry)) {
|
|
114
|
+
return {
|
|
115
|
+
verdict: "foreign",
|
|
116
|
+
shape: looksLikeBridgeShimCommand(entry) ? "shim-with-env-block" : "non-shim-registration",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
for (const allowed of allowedPaths) {
|
|
120
|
+
if (isBridgeApiShimEntry(entry, allowed))
|
|
121
|
+
return { verdict: "safe" };
|
|
122
|
+
}
|
|
123
|
+
if (looksLikeBridgeShimCommand(entry)) {
|
|
124
|
+
if (allowedPaths.length === 0)
|
|
125
|
+
return { verdict: "inconclusive" };
|
|
126
|
+
return { verdict: "foreign", shape: "shim-for-other-project-root" };
|
|
127
|
+
}
|
|
128
|
+
return { verdict: "foreign", shape: "non-shim-registration" };
|
|
129
|
+
}
|
|
130
|
+
/** Resolve a supplied path through the shared registration normalizer, or null. */
|
|
131
|
+
function normalizeOrNull(rawPath, input) {
|
|
132
|
+
if (typeof rawPath !== "string" || rawPath.trim().length === 0)
|
|
133
|
+
return null;
|
|
134
|
+
const normalized = normalizeWorktreePathForRegistration(rawPath, {
|
|
135
|
+
platform: input.platform,
|
|
136
|
+
cwd: input.cwd,
|
|
137
|
+
});
|
|
138
|
+
return normalized.ok ? normalized.path : null;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Inspect `~/.claude.json` for a `bridge-api` registration at user, main-project,
|
|
142
|
+
* and worktree-project scope.
|
|
143
|
+
*
|
|
144
|
+
* Healthy states (no finding, no diagnostic): the file is absent, or it contains
|
|
145
|
+
* no same-named registration in any inspected scope, or every same-named
|
|
146
|
+
* registration is an acceptable provisioned shim.
|
|
147
|
+
*
|
|
148
|
+
* Degraded states (diagnostic, never a confirmed collision): the file could not be
|
|
149
|
+
* read, its JSON is malformed, or a user-scope shim names a project root that
|
|
150
|
+
* cannot be checked because no main-repository path was supplied.
|
|
151
|
+
*
|
|
152
|
+
* Never throws, never writes, never spawns.
|
|
153
|
+
*/
|
|
154
|
+
export async function inspectClaudeUserConfigForMcpShadowing(input, deps) {
|
|
155
|
+
const configPath = input.claudeConfigPath;
|
|
156
|
+
const read = await readJsonIfPresent(configPath, deps);
|
|
157
|
+
if (read.state === "missing")
|
|
158
|
+
return { findings: [], diagnostics: [] };
|
|
159
|
+
if (read.state === "unreadable") {
|
|
160
|
+
return { findings: [], diagnostics: [{ kind: "unreadable", configPath }] };
|
|
161
|
+
}
|
|
162
|
+
if (read.state === "malformed") {
|
|
163
|
+
return { findings: [], diagnostics: [{ kind: "malformed", configPath }] };
|
|
164
|
+
}
|
|
165
|
+
const document = asRecord(read.value);
|
|
166
|
+
if (!document)
|
|
167
|
+
return { findings: [], diagnostics: [] };
|
|
168
|
+
const mainPath = normalizeOrNull(input.mainRepositoryPath, input);
|
|
169
|
+
const worktreePath = normalizeOrNull(input.worktreePath, input);
|
|
170
|
+
// A user-scope shim may legitimately target either checkout the executor is
|
|
171
|
+
// working with; each project scope may only target its own key's path.
|
|
172
|
+
const userAllowed = [mainPath, worktreePath].filter((p) => p !== null);
|
|
173
|
+
const lookups = [{ scope: "user", projectKey: null, allowedPaths: userAllowed }];
|
|
174
|
+
if (mainPath !== null) {
|
|
175
|
+
lookups.push({ scope: "main-project", projectKey: mainPath, allowedPaths: [mainPath] });
|
|
176
|
+
}
|
|
177
|
+
if (worktreePath !== null) {
|
|
178
|
+
lookups.push({
|
|
179
|
+
scope: "worktree-project",
|
|
180
|
+
projectKey: worktreePath,
|
|
181
|
+
allowedPaths: [worktreePath],
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const projects = asRecord(document.projects);
|
|
185
|
+
const findings = [];
|
|
186
|
+
const diagnostics = [];
|
|
187
|
+
// Identical `projects` keys are ONE registration. Dedupe by key while keeping
|
|
188
|
+
// every requested scope it satisfies, so a main path and worktree path that
|
|
189
|
+
// normalize together report once with both scopes rather than twice.
|
|
190
|
+
const findingByProjectKey = new Map();
|
|
191
|
+
for (const lookup of lookups) {
|
|
192
|
+
let servers;
|
|
193
|
+
if (lookup.projectKey === null) {
|
|
194
|
+
servers = asRecord(document.mcpServers);
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
const project = projects ? asRecord(projects[lookup.projectKey]) : null;
|
|
198
|
+
servers = project ? asRecord(project.mcpServers) : null;
|
|
199
|
+
}
|
|
200
|
+
if (!servers)
|
|
201
|
+
continue;
|
|
202
|
+
if (!Object.prototype.hasOwnProperty.call(servers, BRIDGE_API_SERVER_NAME))
|
|
203
|
+
continue;
|
|
204
|
+
const entry = servers[BRIDGE_API_SERVER_NAME];
|
|
205
|
+
const classification = classifyEntry(entry, lookup.allowedPaths);
|
|
206
|
+
if (classification.verdict === "safe")
|
|
207
|
+
continue;
|
|
208
|
+
if (classification.verdict === "inconclusive") {
|
|
209
|
+
diagnostics.push({ kind: "inconclusive-user-shim", configPath });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (lookup.projectKey !== null) {
|
|
213
|
+
const existing = findingByProjectKey.get(lookup.projectKey);
|
|
214
|
+
if (existing) {
|
|
215
|
+
if (!existing.scopes.includes(lookup.scope))
|
|
216
|
+
existing.scopes.push(lookup.scope);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const finding = {
|
|
221
|
+
scopes: [lookup.scope],
|
|
222
|
+
configPath,
|
|
223
|
+
shape: classification.shape,
|
|
224
|
+
envKeyNames: envKeyNamesOf(entry),
|
|
225
|
+
};
|
|
226
|
+
if (lookup.projectKey !== null)
|
|
227
|
+
findingByProjectKey.set(lookup.projectKey, finding);
|
|
228
|
+
findings.push(finding);
|
|
229
|
+
}
|
|
230
|
+
return { findings, diagnostics };
|
|
231
|
+
}
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// Override + shared policy
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
/** Documented truthy override values (trimmed, case-insensitive). */
|
|
236
|
+
const OVERRIDE_TRUTHY = new Set(["1", "true", "yes", "on"]);
|
|
237
|
+
/**
|
|
238
|
+
* True only for a trimmed, case-insensitive `1`, `true`, `yes`, or `on`. Absent,
|
|
239
|
+
* blank, `0`, `false`, `no`, `off`, and anything else are disabled — an override
|
|
240
|
+
* this consequential must be set deliberately, never by a stray value.
|
|
241
|
+
*/
|
|
242
|
+
export function isClaudeMcpShadowingOverrideEnabled(env) {
|
|
243
|
+
const raw = env?.[CONDUCTOR_ALLOW_CLAUDE_MCP_SHADOWING_ENV];
|
|
244
|
+
if (typeof raw !== "string")
|
|
245
|
+
return false;
|
|
246
|
+
return OVERRIDE_TRUTHY.has(raw.trim().toLowerCase());
|
|
247
|
+
}
|
|
248
|
+
/** The exact wording every override path emits, pinned by tests. */
|
|
249
|
+
export const CLAUDE_MCP_SHADOWING_OVERRIDE_WARNING = `Proceeding anyway because ${CONDUCTOR_ALLOW_CLAUDE_MCP_SHADOWING_ENV} is set`;
|
|
250
|
+
/** Human-readable scope labels (fixed strings, never derived from the config). */
|
|
251
|
+
const SCOPE_LABELS = {
|
|
252
|
+
user: "user scope (mcpServers)",
|
|
253
|
+
"main-project": "main-repository project scope (projects[<main repo>])",
|
|
254
|
+
"worktree-project": "worktree project scope (projects[<worktree>])",
|
|
255
|
+
};
|
|
256
|
+
/** Fixed explanation per shape (never derived from the inspected entry). */
|
|
257
|
+
const SHAPE_LABELS = {
|
|
258
|
+
"shim-with-env-block": "a shim-shaped entry carrying an env block (the provisioner never writes env)",
|
|
259
|
+
"shim-for-other-project-root": "an mcp-invoke shim pointed at a different project root",
|
|
260
|
+
"non-shim-registration": "a registration that is not the provisioned mcp-invoke shim",
|
|
261
|
+
};
|
|
262
|
+
/**
|
|
263
|
+
* Render ONE finding as a secret-free sentence built exclusively from the
|
|
264
|
+
* inspector's safe metadata. The registration entry is never serialized.
|
|
265
|
+
*/
|
|
266
|
+
export function formatClaudeMcpShadowFinding(finding) {
|
|
267
|
+
const scopes = finding.scopes.map((scope) => SCOPE_LABELS[scope]).join(" and ");
|
|
268
|
+
const envNote = finding.envKeyNames.length > 0
|
|
269
|
+
? ` (env keys: ${finding.envKeyNames.join(", ")})`
|
|
270
|
+
: "";
|
|
271
|
+
return (`${finding.configPath} registers a '${BRIDGE_API_SERVER_NAME}' MCP server at ${scopes}: ` +
|
|
272
|
+
`${SHAPE_LABELS[finding.shape]}${envNote}`);
|
|
273
|
+
}
|
|
274
|
+
/** Render one degraded diagnostic as a fixed, path-only sentence. */
|
|
275
|
+
export function formatClaudeUserConfigDiagnostic(diagnostic) {
|
|
276
|
+
switch (diagnostic.kind) {
|
|
277
|
+
case "unreadable":
|
|
278
|
+
return `${diagnostic.configPath} could not be read; Claude MCP shadowing could not be checked`;
|
|
279
|
+
case "malformed":
|
|
280
|
+
return `${diagnostic.configPath} contains malformed JSON; Claude MCP shadowing could not be checked`;
|
|
281
|
+
case "inconclusive-user-shim":
|
|
282
|
+
return (`${diagnostic.configPath} registers a user-scope '${BRIDGE_API_SERVER_NAME}' shim for an ` +
|
|
283
|
+
"unverifiable project root (the main repository path could not be resolved)");
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* The SINGLE policy every consumer (executor preflight, per-worktree enforcement,
|
|
288
|
+
* doctor) applies to an inspection result, so none of them re-derives
|
|
289
|
+
* classification or override parsing.
|
|
290
|
+
*
|
|
291
|
+
* Confirmed findings refuse by DEFAULT. With the override enabled the result is OK
|
|
292
|
+
* but carries the pinned prominent warning alongside each finding, so an operator
|
|
293
|
+
* who deliberately opted in still sees exactly what was detected. Degraded
|
|
294
|
+
* diagnostics are always warnings — they are inconclusive, never a collision.
|
|
295
|
+
*/
|
|
296
|
+
export function evaluateClaudeMcpShadowingPolicy(inspection, env) {
|
|
297
|
+
const warnings = inspection.diagnostics.map(formatClaudeUserConfigDiagnostic);
|
|
298
|
+
if (inspection.findings.length === 0)
|
|
299
|
+
return { ok: true, warnings };
|
|
300
|
+
const findingMessages = inspection.findings.map(formatClaudeMcpShadowFinding);
|
|
301
|
+
if (isClaudeMcpShadowingOverrideEnabled(env)) {
|
|
302
|
+
return {
|
|
303
|
+
ok: true,
|
|
304
|
+
warnings: [
|
|
305
|
+
...findingMessages.map((m) => `${m}. ${CLAUDE_MCP_SHADOWING_OVERRIDE_WARNING}`),
|
|
306
|
+
...warnings,
|
|
307
|
+
],
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
ok: false,
|
|
312
|
+
refusals: findingMessages.map((m) => `${m}. Refusing to proceed: worktree MCP provisioning may be shadowed. Delete, or ` +
|
|
313
|
+
`move aside, that registration, or set ` +
|
|
314
|
+
`${CONDUCTOR_ALLOW_CLAUDE_MCP_SHADOWING_ENV}=1 to override.`),
|
|
315
|
+
warnings,
|
|
316
|
+
};
|
|
317
|
+
}
|