@bridge_gpt/mcp-server 0.1.16 → 0.1.17

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/build/index.js CHANGED
@@ -16,7 +16,7 @@ import { writeFile, mkdir, readFile, stat } from "fs/promises";
16
16
  import path from "path";
17
17
  import { execSync } from "child_process";
18
18
  import { createRequire } from "module";
19
- import { PIPELINES as BUNDLED_PIPELINES, INSTRUCTIONS as BUNDLED_INSTRUCTIONS } from "./pipelines.generated.js";
19
+ import { PIPELINES as BUNDLED_PIPELINES, INSTRUCTIONS as BUNDLED_INSTRUCTIONS, CHAIN_RECIPES } from "./pipelines.generated.js";
20
20
  import { COMMANDS } from "./commands.generated.js";
21
21
  import { AGENTS } from "./agents.generated.js";
22
22
  import { VERSION } from "./version.generated.js";
@@ -25,9 +25,11 @@ import { reconstructAgentMarkdown, translateAgentToCopilot } from "./agent-utils
25
25
  import { resolveRecipe, loadCustomPipelines } from "./pipeline-utils.js";
26
26
  import { runStartTicketsCli } from "./start-tickets.js";
27
27
  import { runDoctorCli } from "./doctor.js";
28
+ import { runScheduleRunCli } from "./schedule-run.js";
28
29
  import { generateDecisionPageHtml } from "./decision-page-template.js";
29
30
  import { DecisionPageInputShape } from "./decision-page-schema.js";
30
- import { runPipeline, resumePipeline, listPipelineRuns, deletePipelineRun, } from "./pipeline-orchestrator.js";
31
+ import { runPipeline, resumePipeline, listPipelineRuns, deletePipelineRun, deriveIdeaHash, } from "./pipeline-orchestrator.js";
32
+ import { runFullAutomation, resumeFullAutomation, } from "./chain-orchestrator.js";
31
33
  // Mutable pipeline/instruction state — starts with bundled, merged with user at startup
32
34
  const PIPELINES = { ...BUNDLED_PIPELINES };
33
35
  const INSTRUCTIONS = { ...BUNDLED_INSTRUCTIONS };
@@ -127,6 +129,8 @@ async function createTicketRequest(params) {
127
129
  payload.labels = params.labels;
128
130
  if (params.assignee)
129
131
  payload.assignee = params.assignee;
132
+ if (params.parent_key)
133
+ payload.parent_key = params.parent_key;
130
134
  const resp = await fetch(buildUrl("/create-ticket"), {
131
135
  method: "POST",
132
136
  headers: POST_HEADERS,
@@ -704,6 +708,12 @@ async function dispatchCliSubcommand(argv) {
704
708
  if (argv[0] === "doctor") {
705
709
  return runDoctorCli(argv.slice(1));
706
710
  }
711
+ // The local-only `schedule-run` subcommand (BAPI-327) is likewise a positional
712
+ // subcommand that owns the rest of argv, routed before the --init / --upgrade
713
+ // flag guards and never starts the MCP server.
714
+ if (argv[0] === "schedule-run") {
715
+ return runScheduleRunCli(argv.slice(1));
716
+ }
707
717
  // --init takes precedence over --upgrade; both are position-independent flags.
708
718
  if (argv.includes("--init")) {
709
719
  return runInitCli(cwd);
@@ -854,7 +864,7 @@ registerTool("get_project_standards", {
854
864
  });
855
865
  registerTool("get_tickets", {
856
866
  description: "Search for and list Jira tickets from the configured project. " +
857
- "Filters by query text, status name, or date. Returns up to 'limit' tickets ordered by most recently updated. " +
867
+ "Filters by query text, status name, label, or date. Returns up to 'limit' tickets ordered by most recently updated. " +
858
868
  "All data is fetched live from Jira. Use get_ticket to retrieve full details for a specific ticket.",
859
869
  inputSchema: {
860
870
  query: z
@@ -867,6 +877,12 @@ registerTool("get_tickets", {
867
877
  .string()
868
878
  .optional()
869
879
  .describe("Filter by Jira status name (e.g. 'To Do', 'In Progress', 'Done')"),
880
+ labels: z
881
+ .string()
882
+ .optional()
883
+ .describe("Comma-separated Jira labels. Filters tickets via JQL labels in (...) " +
884
+ "(matches tickets carrying any of the given labels). Labels cannot contain spaces. " +
885
+ "Example: \"bapi-idea-to-ticket-fa-1a2b3c\""),
870
886
  limit: z
871
887
  .number()
872
888
  .optional()
@@ -882,12 +898,14 @@ registerTool("get_tickets", {
882
898
  .optional()
883
899
  .describe("ISO date string (YYYY-MM-DD). Only return tickets updated on or after this date"),
884
900
  },
885
- }, async ({ query, status, limit, offset, updated_since }) => {
901
+ }, async ({ query, status, labels, limit, offset, updated_since }) => {
886
902
  const params = { repo_name: REPO_NAME };
887
903
  if (query)
888
904
  params.query = query;
889
905
  if (status)
890
906
  params.status = status;
907
+ if (labels)
908
+ params.labels = labels;
891
909
  if (limit !== undefined)
892
910
  params.limit = String(limit);
893
911
  if (offset !== undefined && offset > 0)
@@ -936,7 +954,8 @@ registerTool("create_ticket", {
936
954
  description: "Create a new Jira ticket in the configured project. Requires either description or file_path (or both — file_path takes precedence). " +
937
955
  "Returns JSON with {ticket_key: 'PROJ-123', url: 'https://...'}. " +
938
956
  "The ticket is created immediately in Jira — confirm details with the user before calling. " +
939
- "The description field supports Jira markdown formatting.",
957
+ "The description field supports Jira markdown formatting. " +
958
+ "Pass parent_key ONLY when creating a child ticket under an existing Jira Epic; omit it for standalone tickets and for Epic parent creation itself.",
940
959
  inputSchema: {
941
960
  summary: z.string().describe("Ticket title — keep under 100 characters"),
942
961
  description: z
@@ -966,12 +985,17 @@ registerTool("create_ticket", {
966
985
  .string()
967
986
  .optional()
968
987
  .describe("Jira username or account ID of the assignee. Omit to leave unassigned"),
988
+ parent_key: z
989
+ .string()
990
+ .optional()
991
+ .describe("Optional Jira Epic key to set as the parent of the newly created child issue. " +
992
+ "Omit for standalone tickets and Epic parent creation."),
969
993
  },
970
- }, async ({ summary, description, file_path, issue_type, priority, labels, assignee }) => {
994
+ }, async ({ summary, description, file_path, issue_type, priority, labels, assignee, parent_key }) => {
971
995
  const resolved = await resolveTextOrFile(description, file_path, "description");
972
996
  if (!resolved.ok)
973
997
  return resolved.errorResponse;
974
- const text = await createTicketRequest({ summary, description: resolved.text, issue_type, priority, labels, assignee });
998
+ const text = await createTicketRequest({ summary, description: resolved.text, issue_type, priority, labels, assignee, parent_key });
975
999
  return { content: [{ type: "text", text: text + resolved.note }] };
976
1000
  });
977
1001
  registerTool("get_plan", {
@@ -2044,6 +2068,7 @@ const VALID_CONFIG_FIELDS = [
2044
2068
  "post_pr_target_status", "ci_check_config", "ci_followup_config",
2045
2069
  "allow_mutating_smoke_ops",
2046
2070
  "selected_mcp_slugs",
2071
+ "base_branch",
2047
2072
  ].join(", ");
2048
2073
  registerTool("list_config_fields", {
2049
2074
  description: "List all configurable fields available for reading and updating via the Bridge API. " +
@@ -2094,6 +2119,9 @@ registerTool("update_config_field", {
2094
2119
  "The selected_mcp_slugs field takes a JSON array of supported MCP validation manual slug strings " +
2095
2120
  "(e.g. [\"b2c-commerce-developer\", \"playwright-mcp\", \"pwa-kit-mcp\"]) — pass an array of strings, " +
2096
2121
  "not a comma-delimited string; an empty array clears the selection. " +
2122
+ "The base_branch field is a string/null field controlling the development base branch used by PR " +
2123
+ "creation (/create-pr) and start-tickets worktree creation; an empty/null value clears it and " +
2124
+ "automations fall back to 'main'. " +
2097
2125
  "For string fields, omit both value and file_path to set the field to NULL (clearing it). " +
2098
2126
  "Scalar boolean fields are NOT NULL and have no clear/null state: omitting the value writes false " +
2099
2127
  "(matching the API-layer coercion), so pass true/false explicitly."),
@@ -2791,7 +2819,7 @@ registerTool("list_pipelines", {
2791
2819
  const list = Object.entries(PIPELINES).map(([key, pipeline]) => ({
2792
2820
  name: key,
2793
2821
  description: pipeline.description ?? "",
2794
- variables: (pipeline.variables ?? []).filter((v) => v !== "docs_dir"),
2822
+ variables: (pipeline.variables ?? []).filter((v) => v !== "docs_dir" && v !== "idea_hash"),
2795
2823
  source: userPipelineKeys.has(key) ? "user" : "bundled",
2796
2824
  }));
2797
2825
  return {
@@ -2809,7 +2837,7 @@ registerTool("get_pipeline_recipe", {
2809
2837
  .string()
2810
2838
  .describe("Pipeline name (e.g. 'review-ticket', 'implement-ticket')"),
2811
2839
  variables: z
2812
- .record(z.string())
2840
+ .record(z.string(), z.string())
2813
2841
  .optional()
2814
2842
  .describe("Key-value pairs for variable substitution (e.g. { ticket_key: 'BAPI-123' })"),
2815
2843
  skip_steps: z
@@ -2860,6 +2888,11 @@ registerTool("get_pipeline_recipe", {
2860
2888
  auto_approve: auto_approve ? "true" : "",
2861
2889
  ...(variables ?? {}),
2862
2890
  };
2891
+ // Auto-inject the stable idea-hash (like docs_dir) for the standalone
2892
+ // /idea-to-ticket path, mirroring runPipeline.
2893
+ if ("idea" in mergedVariables) {
2894
+ mergedVariables.idea_hash = deriveIdeaHash(mergedVariables.idea);
2895
+ }
2863
2896
  const recipe = resolveRecipe(pipelineDef, INSTRUCTIONS, mergedVariables, skip_steps, !!auto_approve);
2864
2897
  return {
2865
2898
  content: [{
@@ -2912,6 +2945,21 @@ function buildPipelineOrchestratorDeps() {
2912
2945
  toolHandlers: TOOL_HANDLERS,
2913
2946
  };
2914
2947
  }
2948
+ // BAPI-326: dependency injection for the full-automation chain orchestrator.
2949
+ // Same shape as the pipeline deps plus the bundled chain-recipe registry. The
2950
+ // chain orchestrator never imports index.ts; everything flows through here.
2951
+ function buildChainOrchestratorDeps() {
2952
+ return {
2953
+ baseUrl: BASE_URL,
2954
+ apiKey: API_KEY,
2955
+ repoName: REPO_NAME,
2956
+ docsDir: BAPI_DOCS_DIR,
2957
+ pipelines: PIPELINES,
2958
+ chainRecipes: CHAIN_RECIPES,
2959
+ instructions: INSTRUCTIONS,
2960
+ toolHandlers: TOOL_HANDLERS,
2961
+ };
2962
+ }
2915
2963
  registerTool("run_pipeline", {
2916
2964
  description: "Execute a Bridge API pipeline by name. The orchestrator runs steps sequentially, " +
2917
2965
  "dispatching mcp_call steps in-process and pausing on agent_task steps with a " +
@@ -2929,7 +2977,7 @@ registerTool("run_pipeline", {
2929
2977
  .string()
2930
2978
  .describe("Pipeline name (e.g. 'review-ticket', 'implement-ticket')"),
2931
2979
  variables: z
2932
- .record(z.string())
2980
+ .record(z.string(), z.string())
2933
2981
  .optional()
2934
2982
  .describe("Key-value pairs for variable substitution (e.g. { ticket_key: 'BAPI-123' }). " +
2935
2983
  "Do NOT pass `auto_approve` here — use the top-level parameter."),
@@ -3021,6 +3069,85 @@ registerTool("delete_pipeline_run", {
3021
3069
  };
3022
3070
  });
3023
3071
  // ---------------------------------------------------------------------------
3072
+ // Full Automation Chain Tools (BAPI-326)
3073
+ // ---------------------------------------------------------------------------
3074
+ //
3075
+ // run_full_automation / resume_full_automation drive an idea through three
3076
+ // chained stages (idea-to-ticket -> review-ticket fan-out -> start-tickets CLI
3077
+ // seam) via the existing pipeline runner. They share the chain envelope shape
3078
+ // (status: needs_agent_task | completed | failed) with a strict error_code
3079
+ // enum (VALIDATION | NOT_FOUND | EXPIRED | REPO_MISMATCH | TOOL_ERROR). Chain
3080
+ // state is persisted server-side via /jira/chain-runs/runs. Registered AFTER
3081
+ // TOOL_HANDLERS and the pipeline tools so the orchestrator can dispatch the
3082
+ // existing child pipelines in-process.
3083
+ registerTool("run_full_automation", {
3084
+ description: "Run the full-automation chain for an idea: create ticket(s) " +
3085
+ "(idea-to-ticket), review each created ticket (review-ticket fan-out), " +
3086
+ "then emit the exact `/start-tickets ...` command for you to invoke in " +
3087
+ "this same session. Returns the chain envelope keyed on `status`: " +
3088
+ "`needs_agent_task` (perform the `next_action.instruction`, then call " +
3089
+ "`resume_full_automation` with the result as `agent_result`), `completed`, " +
3090
+ "or `failed` (check `error_code`: VALIDATION | NOT_FOUND | EXPIRED | " +
3091
+ "REPO_MISMATCH | TOOL_ERROR). Provide the idea via `idea` or `idea_file` " +
3092
+ "(mutually exclusive).",
3093
+ inputSchema: {
3094
+ idea: z.string().optional(),
3095
+ idea_file: z.string().optional(),
3096
+ auto_approve: z
3097
+ .union([z.boolean(), z.literal("true"), z.literal("false")])
3098
+ .optional(),
3099
+ scheduled_at: z.string().optional(),
3100
+ max_children: z.number().int().positive().optional(),
3101
+ allow_duplicate: z.boolean().optional(),
3102
+ agent: z.enum(["claude"]).optional(),
3103
+ ttl_seconds: z.number().int().positive().optional(),
3104
+ },
3105
+ }, async (input) => {
3106
+ const { idea, idea_file, ...rest } = input;
3107
+ if (idea !== undefined && idea_file !== undefined) {
3108
+ return {
3109
+ content: [{
3110
+ type: "text",
3111
+ text: JSON.stringify({
3112
+ error: "BAD_REQUEST",
3113
+ status: 400,
3114
+ message: "Provide exactly one of `idea` or `idea_file`, not both.",
3115
+ }),
3116
+ }],
3117
+ };
3118
+ }
3119
+ const resolved = await resolveTextOrFile(idea, idea_file, "idea");
3120
+ if (!resolved.ok) {
3121
+ return resolved.errorResponse;
3122
+ }
3123
+ const result = await runFullAutomation(buildChainOrchestratorDeps(), {
3124
+ idea: resolved.text,
3125
+ ...rest,
3126
+ });
3127
+ return {
3128
+ content: [
3129
+ { type: "text", text: JSON.stringify(result, null, 2) },
3130
+ ],
3131
+ };
3132
+ });
3133
+ registerTool("resume_full_automation", {
3134
+ description: "Resume a paused full-automation chain run. Provide the `chain_run_id` " +
3135
+ "returned by the prior needs_agent_task envelope and the string the " +
3136
+ "instruction asked you to produce as `agent_result`. Returns the same " +
3137
+ "chain envelope shape as `run_full_automation`.",
3138
+ inputSchema: {
3139
+ chain_run_id: z.string(),
3140
+ agent_result: z.string(),
3141
+ },
3142
+ }, async (input) => {
3143
+ const result = await resumeFullAutomation(buildChainOrchestratorDeps(), input);
3144
+ return {
3145
+ content: [
3146
+ { type: "text", text: JSON.stringify(result, null, 2) },
3147
+ ],
3148
+ };
3149
+ });
3150
+ // ---------------------------------------------------------------------------
3024
3151
  // generate_decision_page
3025
3152
  // ---------------------------------------------------------------------------
3026
3153
  registerTool("generate_decision_page", {
@@ -16,7 +16,21 @@
16
16
  * are inlined into the instruction string so the agent can review and
17
17
  * confirm before resuming. (BAPI-275 decisions E-4 / E-36.)
18
18
  */
19
+ import { createHash } from "node:crypto";
19
20
  import { resolveRecipe } from "./pipeline-utils.js";
21
+ /**
22
+ * Derive a STABLE, content-addressed hash of an idea — the basis for the
23
+ * cross-run idempotency label ``bapi-idea-hash-{idea_hash}``. Normalizes
24
+ * (trim + lowercase + collapse internal whitespace) so cosmetic edits don't
25
+ * change the hash, then takes the first 12 hex chars of the SHA-256. Unlike the
26
+ * per-run ``run_id`` label, this is identical every time the same idea is run,
27
+ * so a re-run of the same idea is reliably caught by label (not just fuzzy
28
+ * keyword search). Auto-injected as the ``idea_hash`` pipeline variable.
29
+ */
30
+ export function deriveIdeaHash(idea) {
31
+ const normalized = String(idea ?? "").trim().toLowerCase().replace(/\s+/g, " ");
32
+ return createHash("sha256").update(normalized).digest("hex").slice(0, 12);
33
+ }
20
34
  class PipelinePersistenceError extends Error {
21
35
  code;
22
36
  constructor(code, message) {
@@ -175,6 +189,10 @@ const ORCHESTRATION_TOOLS = new Set([
175
189
  "resume_pipeline",
176
190
  "list_pipeline_runs",
177
191
  "delete_pipeline_run",
192
+ // BAPI-326: chain orchestration tools must never be invoked from within a
193
+ // declarative pipeline step (avoids accidental orchestration recursion).
194
+ "run_full_automation",
195
+ "resume_full_automation",
178
196
  ]);
179
197
  // ---------------------------------------------------------------------------
180
198
  // runPipeline
@@ -196,6 +214,12 @@ export async function runPipeline(deps, input) {
196
214
  auto_approve: autoApprove ? "true" : "",
197
215
  ...(input.variables ?? {}),
198
216
  };
217
+ // Auto-inject the stable idea-hash (like docs_dir) whenever the pipeline was
218
+ // given an `idea`, so both the chain and standalone callers get the cross-run
219
+ // idempotency label without computing it themselves.
220
+ if ("idea" in mergedVariables) {
221
+ mergedVariables.idea_hash = deriveIdeaHash(mergedVariables.idea);
222
+ }
199
223
  let recipe;
200
224
  try {
201
225
  recipe = resolveRecipe(pipelineDef, deps.instructions, mergedVariables, undefined, autoApprove);
@@ -227,6 +251,39 @@ export async function runPipeline(deps, input) {
227
251
  }
228
252
  return continuePipelineExecution(deps, persistence, row, recipe, autoApprove);
229
253
  }
254
+ /**
255
+ * Read-only snapshot of a pipeline run's current persisted state — performs no
256
+ * execution and no state transition. Used by the chain orchestrator to decide,
257
+ * at a stage boundary, whether the inner pipeline still needs resuming
258
+ * (``paused``), has already reached a terminal state (``completed`` / ``failed``)
259
+ * so the stage can be advanced/failed idempotently, or is in an indeterminate
260
+ * state that cannot be safely resumed or recovered.
261
+ */
262
+ export async function peekPipelineRun(deps, pipeline_run_id) {
263
+ const persistence = createPipelinePersistenceClient({
264
+ baseUrl: deps.baseUrl,
265
+ apiKey: deps.apiKey,
266
+ repoName: deps.repoName,
267
+ });
268
+ let row;
269
+ try {
270
+ row = await persistence.getRun(pipeline_run_id);
271
+ }
272
+ catch (err) {
273
+ if (err instanceof PipelinePersistenceError) {
274
+ return failedEnvelope(err.code, err.message, { pipeline_run_id });
275
+ }
276
+ return failedEnvelope("TOOL_ERROR", err instanceof Error ? err.message : String(err), { pipeline_run_id });
277
+ }
278
+ return {
279
+ status: row.status,
280
+ pipeline_run_id: row.pipeline_run_id,
281
+ pipeline: row.pipeline_name,
282
+ total_steps: row.resolved_recipe.total_steps,
283
+ current_step_index: row.current_step_index,
284
+ results: row.results,
285
+ };
286
+ }
230
287
  // ---------------------------------------------------------------------------
231
288
  // resumePipeline
232
289
  // ---------------------------------------------------------------------------