@bridge_gpt/mcp-server 0.2.36 → 0.2.38

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 (92) hide show
  1. package/README.md +48 -8
  2. package/build/base-url.js +79 -0
  3. package/build/bridge-api-urls.js +9 -0
  4. package/build/chain-orchestrator.js +93 -15
  5. package/build/claude-user-config-doctor.js +317 -0
  6. package/build/commands.generated.js +2 -1
  7. package/build/conductor/bridge-api-client.js +178 -4
  8. package/build/conductor-bin.js +1 -1
  9. package/build/conductor-bundle-artifacts.js +7 -6
  10. package/build/credential-store.js +205 -4
  11. package/build/direct-ticket-tools.js +70 -0
  12. package/build/doctor.js +239 -80
  13. package/build/executor/cli.js +51 -1
  14. package/build/executor/credentials.js +1 -7
  15. package/build/executor/deps.js +18 -1
  16. package/build/executor/env.js +51 -25
  17. package/build/executor/heartbeat.js +138 -17
  18. package/build/executor/http-client.js +49 -8
  19. package/build/executor/job-errors.js +4 -0
  20. package/build/executor/job-runner.js +422 -22
  21. package/build/executor/observation.js +130 -0
  22. package/build/executor/permissions.js +104 -8
  23. package/build/executor/preflight.js +32 -0
  24. package/build/executor/runner.js +8 -0
  25. package/build/executor/test-clock.js +67 -3
  26. package/build/executor/types.js +4 -1
  27. package/build/executor/worker-command.js +11 -3
  28. package/build/executor/worker-config-isolation.js +287 -0
  29. package/build/executor/worker-finalization.js +68 -14
  30. package/build/executor/worktree.js +46 -4
  31. package/build/index.js +614 -244
  32. package/build/init.js +363 -73
  33. package/build/install-bridge.js +568 -80
  34. package/build/launcher-config-inspection.js +351 -0
  35. package/build/mcp-invoke.js +49 -6
  36. package/build/mcp-provisioning.js +30 -7
  37. package/build/mcp-registration-doctor.js +14 -5
  38. package/build/notifications.js +553 -0
  39. package/build/pipeline-orchestrator.js +146 -4
  40. package/build/pipeline-utils.js +3 -0
  41. package/build/pipelines.generated.js +22 -9
  42. package/build/plan-execution-ledger.js +550 -0
  43. package/build/plan-phase-routing.js +272 -0
  44. package/build/plane/alembic-head.js +110 -0
  45. package/build/plane/build-freshness.js +167 -0
  46. package/build/plane/cli.js +480 -0
  47. package/build/plane/defaults.js +266 -0
  48. package/build/plane/manifest.js +377 -0
  49. package/build/plane/member-logs.js +147 -0
  50. package/build/plane/member-roster.js +147 -0
  51. package/build/plane/preflight.js +289 -0
  52. package/build/plane/shutdown.js +195 -0
  53. package/build/plane/status.js +125 -0
  54. package/build/plane/supervisor.js +569 -0
  55. package/build/plane/test-fakes.js +156 -0
  56. package/build/plane/types.js +75 -0
  57. package/build/readme.generated.js +1 -1
  58. package/build/run-unit-tests-launcher.js +2 -0
  59. package/build/setup-epic.js +662 -27
  60. package/build/sfcc/log-gate.js +38 -11
  61. package/build/sfcc/log-query.js +55 -15
  62. package/build/sfcc/ocapi-shape.js +70 -14
  63. package/build/sfcc/output.js +41 -11
  64. package/build/sfcc/permissions.js +24 -2
  65. package/build/sfcc/read-body.js +92 -0
  66. package/build/sfcc/read-projection.js +185 -0
  67. package/build/sfcc/read-result.js +158 -0
  68. package/build/sfcc/reads-custom-object-def.js +57 -34
  69. package/build/sfcc/reads-site-preference.js +86 -33
  70. package/build/sfcc/reads-system-object.js +50 -38
  71. package/build/sfcc/sfcc-result.js +106 -0
  72. package/build/sfcc/tool-wrapper.js +56 -13
  73. package/build/sfcc/write-grants.js +45 -22
  74. package/build/sfcc/write-guard.js +21 -13
  75. package/build/sfcc/write-result.js +71 -15
  76. package/build/sfcc/write-tool-common.js +126 -32
  77. package/build/sfcc/writes-custom-object-def.js +6 -2
  78. package/build/sfcc/writes-system-object.js +11 -50
  79. package/build/start-tickets-prereqs.js +129 -0
  80. package/build/start-tickets.js +17 -13
  81. package/build/ticket-backend-metadata.js +59 -0
  82. package/build/ticket-key-utils.js +92 -0
  83. package/build/tool-error-envelope.js +71 -0
  84. package/build/tool-surface-gating.js +72 -0
  85. package/build/update-status.js +102 -0
  86. package/build/upgrade-advice.js +47 -0
  87. package/build/upgrade-cli.js +417 -101
  88. package/build/version.generated.js +1 -1
  89. package/build/worktree-core.js +73 -0
  90. package/docs/CONDUCTOR.md +23 -8
  91. package/package.json +3 -3
  92. 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
- The server checks for updates on startup and notes a newer version in your editor's
409
- MCP output log. That check is cached for a day and never blocks startup.
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 Epic**
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,9 @@ 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. Oversized responses are automatically saved to `BAPI_DOCS_DIR/sfcc/` and previewed inline.
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.
798
+
799
+ Attribute-definition reads and writes can return an attribute's `default_value` at `projection: "full"`, and Bridge withholds it — every key is preserved except that one, whose value becomes `[REDACTED_BY_BRIDGE]` — from the inline response, the saved file, and a successful write echo alike. Attribute defaults are intentionally unavailable through this MCP surface; Business Manager is the supported path to read one.
760
800
 
761
801
  **Diagnostics** (always available, no profile needed)
762
802
  - `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,12 +804,12 @@ All SFCC tools are read-only and target a developer sandbox. Oversized responses
764
804
 
765
805
  **System object model** (needs the `sfcc` profile)
766
806
  - `system_object_list` — list system object types (Product, Order, Customer, …).
767
- - `system_object_get` — fetch one type's definition, optionally with its full attribute definitions (`expand_attribute_definitions`).
807
+ - `system_object_get` — fetch one system object type's definition.
768
808
  - `system_object_attribute_search` — search a type's attribute definitions; prefer this over a full dump when hunting a specific `c_` custom attribute.
769
809
 
770
810
  **Custom object definitions** (needs the `sfcc` profile)
771
- - `custom_object_definition_attributes_get` — fetch attribute definitions for a known custom object type. OCAPI cannot enumerate custom object types, so `object_type` must already be known.
772
- - `custom_object_definition_attribute_search` — search attribute definitions within a known custom object type. Read-only — creating a custom object *type* isn't possible via OCAPI; that's a future v2 metadata-import capability.
811
+ - `custom_object_definition_attributes_get` — fetch attribute definitions for a known custom object type (`default_value` withheld). OCAPI cannot enumerate custom object type *IDs* directly, so `object_type` must be known — but it is discoverable: call `system_object_list` at `projection: "full"` for each custom type's `display_name` and `attribute_definition_count`, derive a candidate id (e.g. strip spaces from `"Product Quality Result"` → `ProductQualityResult`), and confirm it by checking that this tool's returned attribute count matches that row's `attribute_definition_count`.
812
+ - `custom_object_definition_attribute_search` — search attribute definitions within a known custom object type (`default_value` withheld). Read-only — creating a custom object *type* isn't possible via OCAPI; that's a future v2 metadata-import capability. Same discovery path as above applies to `object_type`.
773
813
  - `custom_object_definition_attribute_create` — **write** (sandbox only): create an attribute definition on a known custom object type via `PUT /custom_object_definitions/{type}/attribute_definitions/{id}`. TYPE creation is never attempted (the type must pre-exist). Echoes paste-ready OCAPI grant JSON on 403.
774
814
  - `custom_object_definition_attribute_update` — **write** (sandbox only): update an attribute definition via an ETag-conditional `PATCH …/attribute_definitions/{id}`; surfaces 409/412 conflicts and echoes grant JSON on 403.
775
815
 
@@ -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
+ }
@@ -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 Jira ticket keys from a STRUCTURED payload (an object/array
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
- * `JIRA_KEY_EXACT`, and de-dupes in encounter order. There is deliberately NO
175
- * free-text regex fallback: pass the parsed upload-step payload (via
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 (JIRA_KEY_EXACT.test(candidate))
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 typeof value === "string" && JIRA_KEY_EXACT.test(value);
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 Jira key; got ${JSON.stringify(parent)}.`,
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 Jira key. A multi-key list is malformed " +
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