@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
@@ -12,65 +12,26 @@
12
12
  *
13
13
  * Every handler follows the BAPI-582 foundation write recipe: parse inputs →
14
14
  * `rejectIfNotSandboxForWrite` → build a decoupled payload → `ocapiPut`/`ocapiPatch`
15
- * → `formatOcapiWriteToolResult` (which echoes the OCAPI write-grant JSON on 403).
15
+ * → `formatOcapiWriteToolResult`.
16
+ *
17
+ * This module owns no failure representation of its own. Its annotations, result
18
+ * type, path encoder, and every pre-transport failure envelope come from
19
+ * `write-tool-common.ts`, so all nine SFCC write tools emit byte-comparable
20
+ * bodies for equivalent failures (BAPI-766). The private near-duplicates this
21
+ * module used to carry leaked raw Zod issue objects and serialized compactly
22
+ * while the shared helpers pretty-printed — exactly the divergence that made
23
+ * `isError` non-total.
16
24
  */
17
25
  import { z } from "zod";
18
26
  import { ocapiPut, ocapiPatch } from "./client.js";
19
27
  import { withSfccGate } from "./tool-wrapper.js";
20
28
  import { rejectIfNotSandboxForWrite } from "./write-guard.js";
21
29
  import { formatOcapiWriteToolResult } from "./write-result.js";
22
- import { objectAttributeDefinitionCreateBodySchema, objectAttributeDefinitionPatchBodySchema, attributeGroupPatchBodySchema, localizedStringSchema, SfccWritePayloadFault, buildObjectAttributeDefinitionCreatePayload, buildObjectAttributeDefinitionPatchPayload, buildAttributeGroupPutPayload, buildAttributeGroupPatchPayload, buildEmptyRelationPayload, } from "./writes-system-object-payloads.js";
23
- // ---------------------------------------------------------------------------
24
- // Annotations (destructive OCAPI writes against a developer sandbox)
25
- // ---------------------------------------------------------------------------
26
- const WRITE_ANNOTATIONS = {
27
- readOnlyHint: false,
28
- destructiveHint: true,
29
- idempotentHint: false,
30
- openWorldHint: true,
31
- };
32
- function textResult(text) {
33
- return { content: [{ type: "text", text }] };
34
- }
35
- /** Safe 400 envelope for a Zod validation failure — no stacks, no raw thrown strings. */
36
- function zodValidationEnvelope(err) {
37
- return textResult(JSON.stringify({
38
- error: "VALIDATION_ERROR",
39
- status: 400,
40
- message: "Invalid SFCC write tool input.",
41
- issues: err.issues,
42
- }));
43
- }
44
- /** Safe 400 envelope for a local payload-shape fault, in OCAPI-fault vocabulary. */
45
- function payloadFaultEnvelope(fault) {
46
- return textResult(JSON.stringify({
47
- error: "OCAPI_WRITE_ERROR",
48
- status: fault.status,
49
- fault: { type: fault.faultType, message: fault.message },
50
- }));
51
- }
52
- /** Safe 500 envelope — never includes stacks, secrets, headers, or raw error text. */
53
- function unexpectedEnvelope() {
54
- return textResult(JSON.stringify({
55
- error: "INTERNAL_ERROR",
56
- status: 500,
57
- message: "Unexpected SFCC write tool failure",
58
- }));
59
- }
60
- /** Map a pre-transport error (parse or payload-build) to its safe envelope. */
61
- function preTransportErrorEnvelope(err) {
62
- if (err instanceof z.ZodError)
63
- return zodValidationEnvelope(err);
64
- if (err instanceof SfccWritePayloadFault)
65
- return payloadFaultEnvelope(err);
66
- return unexpectedEnvelope();
67
- }
30
+ import { objectAttributeDefinitionCreateBodySchema, objectAttributeDefinitionPatchBodySchema, attributeGroupPatchBodySchema, localizedStringSchema, buildObjectAttributeDefinitionCreatePayload, buildObjectAttributeDefinitionPatchPayload, buildAttributeGroupPutPayload, buildAttributeGroupPatchPayload, buildEmptyRelationPayload, } from "./writes-system-object-payloads.js";
31
+ import { WRITE_ANNOTATIONS, encodedSegment, preTransportErrorEnvelope, unexpectedEnvelope, } from "./write-tool-common.js";
68
32
  // ---------------------------------------------------------------------------
69
33
  // OCAPI path builders (every dynamic segment URL-encoded)
70
34
  // ---------------------------------------------------------------------------
71
- function encodedSegment(segment) {
72
- return encodeURIComponent(segment);
73
- }
74
35
  function attributeDefinitionPath(objectType, attributeId) {
75
36
  return `/system_object_definitions/${encodedSegment(objectType)}/attribute_definitions/${encodedSegment(attributeId)}`;
76
37
  }
@@ -18,6 +18,7 @@ import { resolveBapiCredentials, getPrimaryCredentialStorePath, } from "./creden
18
18
  import { resolveStartTicketsRepoName } from "./start-tickets-repo.js";
19
19
  import { probeWorktreeMcpRegistration } from "./mcp-registration-doctor.js";
20
20
  import { probeWorktreeCommandAssets } from "./command-assets-doctor.js";
21
+ import { CONDUCTOR_ALLOW_CLAUDE_MCP_SHADOWING_ENV, formatClaudeMcpShadowFinding, formatClaudeUserConfigDiagnostic, inspectClaudeUserConfigForMcpShadowing, resolveClaudeUserConfigPath, } from "./claude-user-config-doctor.js";
21
22
  // ---------------------------------------------------------------------------
22
23
  // Constants (moved here from start-tickets.ts so both consumers share them)
23
24
  // ---------------------------------------------------------------------------
@@ -480,6 +481,126 @@ export function worktreeCommandAssetsDescriptor() {
480
481
  },
481
482
  };
482
483
  }
484
+ // ---------------------------------------------------------------------------
485
+ // BAPI-727 Claude user-configuration MCP shadowing. Doctor-only and strictly
486
+ // read-only: it reads `~/.claude.json` and runs `git worktree list --porcelain`
487
+ // (a non-mutating query) to learn which checkout is the MAIN worktree, then
488
+ // reports whether a foreign `bridge-api` registration could shadow the
489
+ // provisioning a conductor worker depends on.
490
+ // ---------------------------------------------------------------------------
491
+ /** Stable descriptor id — pinned by tests and by the doctor render. */
492
+ export const CLAUDE_MCP_SHADOWING_ID = "claude-mcp-shadowing";
493
+ /**
494
+ * Read-only resolution of the MAIN worktree path via
495
+ * `git worktree list --porcelain` under `deps.cwd`. The porcelain format lists
496
+ * the main checkout FIRST, one record per blank-line-separated block whose first
497
+ * line is `worktree <path>`.
498
+ *
499
+ * Degrades to `null` on a non-zero exit, a thrown runner, or malformed/empty
500
+ * output — never throws, and never retains Git's stderr or an exception message.
501
+ * A null result means "the main path is unknown", which the descriptor reports as
502
+ * degraded uncertainty rather than as a collision.
503
+ */
504
+ export async function resolveMainWorktreePathForDoctor(deps) {
505
+ let result;
506
+ try {
507
+ result = await deps.runCommand("git", ["worktree", "list", "--porcelain"], {
508
+ cwd: deps.cwd,
509
+ });
510
+ }
511
+ catch {
512
+ return null;
513
+ }
514
+ if (!commandSucceeded(result))
515
+ return null;
516
+ for (const line of result.stdout.split(/\r?\n/)) {
517
+ if (!line.startsWith("worktree "))
518
+ continue;
519
+ const candidate = line.slice("worktree ".length).trim();
520
+ return candidate.length > 0 ? candidate : null;
521
+ }
522
+ return null;
523
+ }
524
+ /**
525
+ * Secret-free remediation hint shared by the Claude MCP shadowing descriptor.
526
+ *
527
+ * WORDING CONSTRAINT: this module is guarded by a static source scan
528
+ * (`start-tickets-credential-invariants.static.test.ts`) proving the prereqs
529
+ * module performs no filesystem writes. It rejects the bare filesystem-mutation
530
+ * identifiers ANYWHERE in the source — including comments and user-facing prose,
531
+ * which is why the advice below is phrased as "delete, or move aside". Keep every
532
+ * such identifier out of this file, even inside a comment like this one.
533
+ */
534
+ const CLAUDE_MCP_SHADOWING_HINT = "Delete, or move aside, the 'bridge-api' MCP registration in ~/.claude.json (at user scope, " +
535
+ "or under the projects entry for this repository/worktree) so the registration the " +
536
+ "conductor provisions into each worker worktree is the one that takes effect. If the " +
537
+ "collision is deliberate, set " +
538
+ `${CONDUCTOR_ALLOW_CLAUDE_MCP_SHADOWING_ENV}=1 to override the executor's refusal ` +
539
+ "(doctor still reports it).";
540
+ const CLAUDE_MCP_SHADOWING_INSTALL_HINTS = {
541
+ darwin: CLAUDE_MCP_SHADOWING_HINT,
542
+ linux: CLAUDE_MCP_SHADOWING_HINT,
543
+ win32: CLAUDE_MCP_SHADOWING_HINT,
544
+ };
545
+ /**
546
+ * Doctor-only, strictly read-only probe: does the machine's Claude user
547
+ * configuration register a `bridge-api` MCP server that could shadow a worker's
548
+ * provisioned registration?
549
+ *
550
+ * Status mapping:
551
+ * - no registration, or only acceptable provisioned shims → `found: true`
552
+ * - a CONFIRMED foreign registration → `found: false`
553
+ * - unreadable / malformed config, or an unresolved main
554
+ * worktree path → `found: true` + detail
555
+ *
556
+ * The failing case is deliberately independent of
557
+ * `BAPI_CONDUCTOR_ALLOW_CLAUDE_MCP_SHADOWING`: the override changes what the
558
+ * EXECUTOR does at runtime, not whether the collision exists. A diagnostic that
559
+ * went quiet because an override happened to be exported would hide the exact
560
+ * condition an operator ran doctor to find.
561
+ */
562
+ export function claudeMcpShadowingDescriptor() {
563
+ return {
564
+ id: CLAUDE_MCP_SHADOWING_ID,
565
+ label: "Claude user-config MCP shadowing (~/.claude.json)",
566
+ installHint: CLAUDE_MCP_SHADOWING_INSTALL_HINTS,
567
+ // The detail below is built exclusively from the inspector's safe metadata
568
+ // (scope labels, the config path, a fixed shape label, sorted env KEY names),
569
+ // so it is safe to render even when this prerequisite reads as missing.
570
+ renderDetailWhenMissing: true,
571
+ probe: async (deps) => {
572
+ const { readFile, homedir } = deps;
573
+ if (!readFile || !homedir) {
574
+ return {
575
+ found: true,
576
+ detail: "shadowing probe unavailable (no read-only filesystem access)",
577
+ };
578
+ }
579
+ const mainWorktreePath = await resolveMainWorktreePathForDoctor(deps);
580
+ const inspection = await inspectClaudeUserConfigForMcpShadowing({
581
+ claudeConfigPath: resolveClaudeUserConfigPath(homedir(), deps.platform),
582
+ platform: deps.platform,
583
+ cwd: deps.cwd,
584
+ mainRepositoryPath: mainWorktreePath ?? undefined,
585
+ worktreePath: deps.cwd,
586
+ }, { readFile });
587
+ const degraded = inspection.diagnostics.map(formatClaudeUserConfigDiagnostic);
588
+ if (mainWorktreePath === null) {
589
+ degraded.push("the main worktree path could not be resolved; only user and current-worktree scopes were checked");
590
+ }
591
+ if (inspection.findings.length > 0) {
592
+ const findings = inspection.findings.map(formatClaudeMcpShadowFinding);
593
+ return { found: false, detail: [...findings, ...degraded].join("; ") };
594
+ }
595
+ return {
596
+ found: true,
597
+ detail: degraded.length > 0
598
+ ? degraded.join("; ")
599
+ : "no foreign bridge-api registration in the Claude user config",
600
+ };
601
+ },
602
+ };
603
+ }
483
604
  /**
484
605
  * Conservatively normalize a filesystem path for comparison: trim, drop a
485
606
  * trailing separator, and resolve `.`/`..` segments via `path.resolve`. Symlink
@@ -628,6 +749,7 @@ export function getDoctorOnlyPrereqDescriptors(_platform, _env, agent) {
628
749
  ripgrepDescriptor(),
629
750
  reviewTicketsGitDescriptor(),
630
751
  liveSourceGuardDescriptor(),
752
+ claudeMcpShadowingDescriptor(),
631
753
  ];
632
754
  }
633
755
  /**
@@ -648,10 +770,12 @@ export function getDoctorPrereqDescriptors(platform, env, agent) {
648
770
  /** Probe one descriptor without throwing, resolving its platform-specific hint. */
649
771
  export async function probePrerequisite(deps, descriptor) {
650
772
  let outcome;
773
+ let probeThrew = false;
651
774
  try {
652
775
  outcome = await descriptor.probe(deps);
653
776
  }
654
777
  catch (err) {
778
+ probeThrew = true;
655
779
  outcome = { found: false, detail: err instanceof Error ? err.message : String(err) };
656
780
  }
657
781
  return {
@@ -661,6 +785,11 @@ export async function probePrerequisite(deps, descriptor) {
661
785
  detail: outcome.detail,
662
786
  installHint: hintForPlatform(descriptor.installHint, deps.platform),
663
787
  authNote: descriptor.authNote,
788
+ // Deliberately NOT carried through when the probe THREW: the catch above
789
+ // replaces the audited detail with the exception's message, which is exactly
790
+ // the arbitrary text the missing-detail suppression exists to keep out of the
791
+ // report. The audit only covers details the probe returned deliberately.
792
+ renderDetailWhenMissing: descriptor.renderDetailWhenMissing === true && !probeThrew ? true : undefined,
664
793
  };
665
794
  }
666
795
  /**
@@ -64,6 +64,7 @@ import { provisionMcpRegistrationsForCreatedWorktrees, } from "./mcp-provisionin
64
64
  import { provisionCommandsForCreatedWorktrees, } from "./command-provisioning.js";
65
65
  import { resolveMcpShimInvocationForRuntime, buildMcpShimCommand, } from "./mcp-server-invocation.js";
66
66
  import { existsSync } from "node:fs";
67
+ import { TICKET_KEY_PATTERN as SHARED_TICKET_KEY_PATTERN, TICKET_KEY_USAGE_SUMMARY, validateTicketKey, } from "./ticket-key-utils.js";
67
68
  // Per-OS prerequisite knowledge + low-level command probes live in the shared
68
69
  // prereqs module so `runPreflight` (enforce) and the read-only `doctor` (render)
69
70
  // can never drift. `start-tickets.ts` imports VALUES from there; the prereqs
@@ -80,8 +81,15 @@ export { WORKTRUNK_BINARY_OVERRIDE_ENV, WINDOWS_TERMINAL_COMMAND, WINDOWS_POWERS
80
81
  // ---------------------------------------------------------------------------
81
82
  // Constants
82
83
  // ---------------------------------------------------------------------------
83
- /** Jira-style ticket key, e.g. BAPI-248. */
84
- export const TICKET_KEY_PATTERN = /^[A-Z]+-[0-9]+$/;
84
+ /**
85
+ * The canonical ticket-key shape, e.g. `BAPI-248` or `MY_PROJ-248`.
86
+ *
87
+ * Re-exported from `ticket-key-utils.ts` (BAPI-710) rather than redefined:
88
+ * `review-tickets.ts` still imports the name from here, and the previous local
89
+ * `/^[A-Z]+-[0-9]+$/` rejected underscore-containing and single-character
90
+ * prefixes that the server happily mints.
91
+ */
92
+ export const TICKET_KEY_PATTERN = SHARED_TICKET_KEY_PATTERN;
85
93
  /** Default cap on concurrently-created worktrees. */
86
94
  export const DEFAULT_MAX_PARALLEL = 3;
87
95
  /** Default tmux session-name prefix; one detached session is created per ticket. */
@@ -146,7 +154,7 @@ export function getStartTicketsUsage() {
146
154
  " Windows git-wt, Git for Windows / Git Bash, Windows Terminal or PowerShell",
147
155
  " Linux wt, git, tmux",
148
156
  "",
149
- "Each KEY must match [A-Z]+-[0-9]+ (e.g., BAPI-248).",
157
+ TICKET_KEY_USAGE_SUMMARY,
150
158
  ].join("\n");
151
159
  }
152
160
  /**
@@ -366,11 +374,9 @@ export function parseStartTicketsArgs(argv) {
366
374
  }
367
375
  const seen = new Set();
368
376
  for (const key of keys) {
369
- if (!TICKET_KEY_PATTERN.test(key)) {
370
- return {
371
- status: "error",
372
- message: `Invalid ticket key: '${key}' (keys must match [A-Z]+-[0-9]+, e.g., BAPI-248).`,
373
- };
377
+ const keyCheck = validateTicketKey(key);
378
+ if (!keyCheck.ok) {
379
+ return { status: "error", message: keyCheck.message };
374
380
  }
375
381
  if (seen.has(key)) {
376
382
  return { status: "error", message: `Duplicate ticket key: '${key}'.` };
@@ -400,11 +406,9 @@ export function parseStartTicketsArgs(argv) {
400
406
  }
401
407
  const overrideKey = entry.slice(0, sepIndex);
402
408
  const branchName = entry.slice(sepIndex + 1);
403
- if (!TICKET_KEY_PATTERN.test(overrideKey)) {
404
- return {
405
- status: "error",
406
- message: `Invalid --branch override key: '${overrideKey}' (keys must match [A-Z]+-[0-9]+).`,
407
- };
409
+ const overrideCheck = validateTicketKey(overrideKey, "--branch override key");
410
+ if (!overrideCheck.ok) {
411
+ return { status: "error", message: overrideCheck.message };
408
412
  }
409
413
  if (!seen.has(overrideKey)) {
410
414
  return {
@@ -0,0 +1,59 @@
1
+ /**
2
+ * ticket-backend-metadata — attach the server's `X-Ticket-Backend` header to a
3
+ * SUCCESSFUL ticket response as labeled metadata (BAPI-710).
4
+ *
5
+ * The server has always set this header; nothing on the MCP side read it, so an
6
+ * agent could not tell whether a ticket came from Jira or the local backend —
7
+ * which matters because a `local://` URL, an absent comment thread, and a
8
+ * four-status workflow are all correct for one backend and a symptom for the other.
9
+ *
10
+ * Two rules hold this in its lane:
11
+ *
12
+ * - **Metadata only, success only.** The header is never an authorization or
13
+ * error-control signal. Error responses are governed exclusively by the
14
+ * structured 409 body (`tool-error-envelope.ts`), because a raised FastAPI
15
+ * error builds a fresh response and DROPS this header — so treating its
16
+ * absence as meaningful would misread every error.
17
+ * - **The payload survives intact.** An object gains one labeled field; an
18
+ * array or scalar is wrapped in an explicit `{ ticket_backend, data }`
19
+ * envelope rather than being flattened, coerced, or dropped.
20
+ */
21
+ /** The response header the Bridge API sets on ticket routes. */
22
+ export const TICKET_BACKEND_HEADER = "X-Ticket-Backend";
23
+ /** The field name added to a successful response payload. */
24
+ export const TICKET_BACKEND_FIELD = "ticket_backend";
25
+ /** Narrow a raw header value to a recognized backend, or `null`. */
26
+ export function parseTicketBackendHeader(value) {
27
+ if (typeof value !== "string")
28
+ return null;
29
+ const normalized = value.trim().toLowerCase();
30
+ if (normalized === "local" || normalized === "jira")
31
+ return normalized;
32
+ return null;
33
+ }
34
+ /**
35
+ * Return the response body to serialize, with backend metadata attached.
36
+ *
37
+ * - `backend === null` (header absent or unrecognized) → `body` unchanged. A
38
+ * supplemental signal must never alter a payload just by being missing.
39
+ * - plain object → the original fields plus `ticket_backend`.
40
+ * - array / scalar / null → `{ ticket_backend, data: <original> }`, because
41
+ * there is nowhere on an array to put a labeled field without changing what
42
+ * the array means.
43
+ */
44
+ export function attachTicketBackendMetadata(body, backend) {
45
+ if (backend === null)
46
+ return body;
47
+ if (body !== null && typeof body === "object" && !Array.isArray(body)) {
48
+ return { ...body, [TICKET_BACKEND_FIELD]: backend };
49
+ }
50
+ return { [TICKET_BACKEND_FIELD]: backend, data: body };
51
+ }
52
+ /**
53
+ * Serialize a successful JSON response with backend metadata attached.
54
+ * Formatting matches `handleResponse`'s existing `JSON.stringify(body, null, 2)`.
55
+ */
56
+ export function formatSuccessWithTicketBackend(body, headerValue) {
57
+ const backend = parseTicketBackendHeader(headerValue);
58
+ return JSON.stringify(attachTicketBackendMetadata(body, backend), null, 2);
59
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * ticket-key-utils — the ONE ticket-key grammar every MCP and CLI surface uses.
3
+ *
4
+ * Before BAPI-710 three patterns disagreed across the client/server boundary:
5
+ *
6
+ * | Layer | Pattern | Accepts `MY_PROJ` |
7
+ * | ------------------------------------------- | ------------------------ | ----------------- |
8
+ * | server `field_validators.py` (prefix) | `^[A-Z][A-Z0-9_]{0,19}$` | yes |
9
+ * | `chain-orchestrator.ts` | `^[A-Z][A-Z0-9]+-\d+$` | no |
10
+ * | `start-tickets.ts` | `^[A-Z]+-[0-9]+$` | no |
11
+ *
12
+ * A repository configured with `jira_ticket_key = "MY_PROJ"` therefore minted
13
+ * keys the server considered valid and the clients rejected — `/full-automation`
14
+ * silently dropped them. Jira project keys may contain underscores too, so this
15
+ * was never a local-mode-only defect.
16
+ *
17
+ * This module mirrors the public Python contract
18
+ * (`api.library.config.field_validators.TICKET_KEY_PREFIX_PATTERN_SOURCE` plus a
19
+ * `-<digits>` suffix). The two must be changed together; the Python side is
20
+ * authoritative because it is what mints keys.
21
+ *
22
+ * Everything here is pure: no I/O, no normalization. A key that reaches a client
23
+ * has already been minted, so it must round-trip byte-for-byte — upper-casing or
24
+ * trimming it here would silently retarget or invent a ticket.
25
+ */
26
+ /** The configured prefix grammar, mirroring the server's public prefix source. */
27
+ export const TICKET_KEY_PREFIX_PATTERN_SOURCE = "[A-Z][A-Z0-9_]{0,19}";
28
+ /**
29
+ * A complete ticket key: an upper-case prefix (a letter followed by up to 19
30
+ * letters, digits, or underscores), a hyphen, and a numeric suffix.
31
+ * Examples: `A-1`, `BAPI-248`, `MY_PROJ-248`, `A1_B2-0007`.
32
+ */
33
+ export const TICKET_KEY_PATTERN = /^[A-Z][A-Z0-9_]{0,19}-\d+$/;
34
+ /**
35
+ * The single actionable rejection message. Names every rule the grammar
36
+ * enforces, so one failure is enough for a caller to correct its input.
37
+ */
38
+ export const TICKET_KEY_VALIDATION_MESSAGE = "keys must be an upper-case prefix (a letter followed by up to 19 letters, " +
39
+ "digits, or underscores — 20 characters maximum), a hyphen, and a number " +
40
+ "(e.g., BAPI-248, MY_PROJ-248, A-1)";
41
+ /** Compact contract summary for usage/help text. */
42
+ export const TICKET_KEY_USAGE_SUMMARY = "Each KEY is an upper-case prefix (letters, digits, underscores; 20 max), a " +
43
+ "hyphen, and a number (e.g., BAPI-248, MY_PROJ-248).";
44
+ /** True when `value` is exactly a canonical ticket key. Never normalizes. */
45
+ export function isValidTicketKey(value) {
46
+ return typeof value === "string" && TICKET_KEY_PATTERN.test(value);
47
+ }
48
+ /**
49
+ * Validate a single key, returning an actionable message on failure.
50
+ *
51
+ * The rejected value is echoed via `JSON.stringify` so an empty string, a value
52
+ * with trailing whitespace, or a non-string is visibly distinguishable in the
53
+ * error — "Invalid ticket key: ''" is otherwise indistinguishable from a bug in
54
+ * the caller's own formatting.
55
+ */
56
+ export function validateTicketKey(value, label = "ticket key") {
57
+ if (isValidTicketKey(value))
58
+ return { ok: true };
59
+ const rendered = typeof value === "string" ? value : JSON.stringify(value);
60
+ return {
61
+ ok: false,
62
+ invalidKeys: [String(rendered)],
63
+ message: `Invalid ${label}: ${JSON.stringify(rendered)} — ${TICKET_KEY_VALIDATION_MESSAGE}.`,
64
+ };
65
+ }
66
+ /**
67
+ * Validate a whole list and report EVERY rejected key in one message.
68
+ *
69
+ * Reporting the complete rejected set (rather than the first failure, or —
70
+ * worse — silently continuing with the valid subset) is the point: a caller that
71
+ * learns about one bad key at a time needs one round trip per key, and a caller
72
+ * that is never told at all runs downstream side effects against a quietly
73
+ * truncated list.
74
+ */
75
+ export function validateTicketKeys(values, label = "ticket key") {
76
+ const invalidKeys = [];
77
+ for (const value of values) {
78
+ if (isValidTicketKey(value))
79
+ continue;
80
+ invalidKeys.push(typeof value === "string" ? value : JSON.stringify(value));
81
+ }
82
+ if (invalidKeys.length === 0)
83
+ return { ok: true };
84
+ const plural = invalidKeys.length === 1 ? "" : "s";
85
+ return {
86
+ ok: false,
87
+ invalidKeys,
88
+ message: `Invalid ${label}${plural}: ` +
89
+ `${invalidKeys.map((k) => JSON.stringify(k)).join(", ")} — ` +
90
+ `${TICKET_KEY_VALIDATION_MESSAGE}.`,
91
+ };
92
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * tool-error-envelope — pure parsing for the serialized MCP error envelopes that
3
+ * `handleResponse` (index.ts) emits for a non-OK Bridge API response.
4
+ *
5
+ * The envelope shape is `{ error: string, status: number, message: string, ... }`.
6
+ * One condition is recognized specifically (BAPI-710): the backend's terminal
7
+ * "this operation has no local-backend equivalent" refusal, which the ticket
8
+ * routes return as HTTP 409 with `detail.error = "UNSUPPORTED_IN_LOCAL_MODE"`.
9
+ *
10
+ * Recognition is by the exact `(error, status)` PAIR — never by message text.
11
+ * Message text is prose that gets reworded; matching on it would make a copy
12
+ * edit silently change orchestration control flow.
13
+ *
14
+ * Everything here is pure: parse, inspect, return. Malformed JSON, scalars,
15
+ * arrays, and absent fields all produce a safe non-match rather than throwing.
16
+ */
17
+ /** The recognized machine-readable marker for an unsupported local operation. */
18
+ export const UNSUPPORTED_IN_LOCAL_MODE_ERROR = "UNSUPPORTED_IN_LOCAL_MODE";
19
+ /** The HTTP status that marker is only ever valid with. */
20
+ export const UNSUPPORTED_IN_LOCAL_MODE_STATUS = 409;
21
+ /**
22
+ * Parse `text` as a tool-error envelope, or return `null`.
23
+ *
24
+ * Requires a JSON object with a string `error` and a numeric `status` — the same
25
+ * strict shape `pipeline-orchestrator.ts`'s `isToolFailureEnvelope` uses, so the
26
+ * two agree on what counts as a failure envelope.
27
+ */
28
+ export function parseToolErrorEnvelope(text) {
29
+ if (typeof text !== "string")
30
+ return null;
31
+ let parsed;
32
+ try {
33
+ parsed = JSON.parse(text);
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
39
+ return null;
40
+ }
41
+ const obj = parsed;
42
+ if (typeof obj.error !== "string" || typeof obj.status !== "number")
43
+ return null;
44
+ const envelope = {
45
+ error: obj.error,
46
+ status: obj.status,
47
+ message: typeof obj.message === "string" ? obj.message : "",
48
+ };
49
+ if (Array.isArray(obj.supported_in)) {
50
+ envelope.supported_in = obj.supported_in.filter((v) => typeof v === "string");
51
+ }
52
+ if (Array.isArray(obj.unsupported_fields)) {
53
+ envelope.unsupported_fields = obj.unsupported_fields.filter((v) => typeof v === "string");
54
+ }
55
+ return envelope;
56
+ }
57
+ /** True for the exact unsupported-local contract: the marker AND HTTP 409. */
58
+ export function isUnsupportedInLocalModeEnvelope(envelope) {
59
+ return (envelope !== null &&
60
+ envelope.error === UNSUPPORTED_IN_LOCAL_MODE_ERROR &&
61
+ envelope.status === UNSUPPORTED_IN_LOCAL_MODE_STATUS);
62
+ }
63
+ /**
64
+ * Convenience: parse `text` and return the envelope only when it is the
65
+ * recognized unsupported-local refusal. Returns `null` for everything else,
66
+ * including an ordinary 409 and a 500 that happens to carry the marker string.
67
+ */
68
+ export function parseUnsupportedInLocalModeEnvelope(text) {
69
+ const envelope = parseToolErrorEnvelope(text);
70
+ return isUnsupportedInLocalModeEnvelope(envelope) ? envelope : null;
71
+ }
@@ -371,6 +371,78 @@ function setsEqual(a, b) {
371
371
  }
372
372
  return true;
373
373
  }
374
+ // ---------------------------------------------------------------------------
375
+ // Update-staleness advisory decoration (BAPI-728)
376
+ // ---------------------------------------------------------------------------
377
+ /** The `_meta` key carrying the structured staleness advisory. */
378
+ export const UPDATE_ADVISORY_META_KEY = "bridge-api/update-advisory";
379
+ /** The tool whose description carries the model-visible advisory when stale. */
380
+ const ADVISORY_HOST_TOOL = "ping";
381
+ /**
382
+ * Wrap a `tools/list` handler so a KNOWN-stale server tells the model so.
383
+ *
384
+ * Why decorate the list at all: the staleness value was already computed on every
385
+ * server start and emitted to an MCP log sink nothing reads, so a session talking
386
+ * to a twenty-release-old build looked completely normal — the visible symptom was
387
+ * an agent that could not find `council`, with no signal explaining why. Tool
388
+ * discovery is the one surface every session reads without being asked to.
389
+ *
390
+ * What is preserved, exactly: tool NAMES, their ORDER, their input/output SCHEMAS,
391
+ * their enabled state, and every other response property. The advisory is added in
392
+ * two places, both additive:
393
+ *
394
+ * - `_meta` — the protocol's sanctioned extension point, for clients that
395
+ * surface structured metadata;
396
+ * - the `ping` tool's DESCRIPTION — the surface a model actually reads. `ping`
397
+ * is the topically correct host: its description already documents the
398
+ * version/upgrade metadata it returns. Note this does not require anyone to
399
+ * CALL `ping` — reading its description during ordinary discovery is enough,
400
+ * which is the precise gap the ticket describes.
401
+ *
402
+ * The description text is appended at RUNTIME only. The static tool catalog is
403
+ * untouched, so the tools-budget ratchet and the description prose pins that
404
+ * guard `mcp_server/src/index.ts` are unaffected.
405
+ *
406
+ * Fail-open: if the advisory cannot be produced, the original result is returned
407
+ * byte-for-byte.
408
+ */
409
+ export function decorateListResultWithUpdateAdvisory(original, advisory) {
410
+ if (!advisory)
411
+ return original;
412
+ try {
413
+ const tools = original.tools.map((tool) => tool.name === ADVISORY_HOST_TOOL
414
+ ? { ...tool, description: `${advisory}\n\n${tool.description ?? ""}`.trimEnd() }
415
+ : tool);
416
+ return {
417
+ ...original,
418
+ tools,
419
+ _meta: { ...(original._meta ?? {}), [UPDATE_ADVISORY_META_KEY]: advisory },
420
+ };
421
+ }
422
+ catch {
423
+ return original;
424
+ }
425
+ }
426
+ /**
427
+ * Compose an advisory-decorating handler over `inner`.
428
+ *
429
+ * `inner` is the capability gate's handler when dynamic gating is enabled, and
430
+ * the SDK's own captured handler when it is disabled — so turning gating off
431
+ * never turns update visibility off with it.
432
+ */
433
+ export function createUpdateAdvisoryListHandler(inner, getAdvisory, onListServed) {
434
+ return async (request, extra) => {
435
+ const result = await inner(request, extra);
436
+ try {
437
+ onListServed?.();
438
+ return decorateListResultWithUpdateAdvisory(result, getAdvisory());
439
+ }
440
+ catch {
441
+ // Never let advisory decoration break tool discovery.
442
+ return result;
443
+ }
444
+ };
445
+ }
374
446
  /** Fixed, sanitized boot error (never includes map contents or config). */
375
447
  const COMPAT_ERROR = "tool-surface gating: incompatible MCP SDK — the tools/list handler could not be resolved for override.";
376
448
  /**