@bridge_gpt/mcp-server 0.1.14 → 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,16 +16,20 @@ 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";
23
23
  import { checkForUpdate } from "./update-check.js";
24
24
  import { reconstructAgentMarkdown, translateAgentToCopilot } from "./agent-utils.js";
25
25
  import { resolveRecipe, loadCustomPipelines } from "./pipeline-utils.js";
26
+ import { runStartTicketsCli } from "./start-tickets.js";
27
+ import { runDoctorCli } from "./doctor.js";
28
+ import { runScheduleRunCli } from "./schedule-run.js";
26
29
  import { generateDecisionPageHtml } from "./decision-page-template.js";
27
30
  import { DecisionPageInputShape } from "./decision-page-schema.js";
28
- 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";
29
33
  // Mutable pipeline/instruction state — starts with bundled, merged with user at startup
30
34
  const PIPELINES = { ...BUNDLED_PIPELINES };
31
35
  const INSTRUCTIONS = { ...BUNDLED_INSTRUCTIONS };
@@ -125,6 +129,8 @@ async function createTicketRequest(params) {
125
129
  payload.labels = params.labels;
126
130
  if (params.assignee)
127
131
  payload.assignee = params.assignee;
132
+ if (params.parent_key)
133
+ payload.parent_key = params.parent_key;
128
134
  const resp = await fetch(buildUrl("/create-ticket"), {
129
135
  method: "POST",
130
136
  headers: POST_HEADERS,
@@ -610,41 +616,54 @@ body explicitly says to serialize structured output.
610
616
  }
611
617
  }
612
618
  // ---------------------------------------------------------------------------
613
- // CLI: --init
619
+ // CLI subcommand dispatch
614
620
  // ---------------------------------------------------------------------------
615
- if (process.argv.includes("--init")) {
621
+ //
622
+ // `--init`, `--upgrade`, and the positional `start-tickets` subcommand are all
623
+ // routed through a single ``dispatchCliSubcommand`` guard that runs and exits
624
+ // BEFORE the MCP server is constructed and connected. This keeps the single
625
+ // existing ``bridge-api-mcp-server`` bin and leaves a clean seam for future
626
+ // subcommands.
627
+ /**
628
+ * Ensure a ``package.json`` exists in ``cwd`` for CLI commands that scaffold
629
+ * into a project. Returns ``null`` on success, or the exact user-facing error
630
+ * string (preserved verbatim from the previous standalone guards).
631
+ */
632
+ async function ensurePackageJsonForCliCommand(flagName, cwd) {
616
633
  try {
617
- await stat(path.join(process.cwd(), "package.json"));
634
+ await stat(path.join(cwd, "package.json"));
635
+ return null;
618
636
  }
619
637
  catch {
620
- console.error("Error: No package.json found in current directory.\n" +
621
- "--init must be run from your project root (the directory containing package.json).");
622
- process.exit(1);
638
+ return ("Error: No package.json found in current directory.\n" +
639
+ `${flagName} must be run from your project root (the directory containing package.json).`);
640
+ }
641
+ }
642
+ /** Run the ``--init`` scaffolding flow. Returns a process exit code. */
643
+ async function runInitCli(cwd) {
644
+ const guardError = await ensurePackageJsonForCliCommand("--init", cwd);
645
+ if (guardError) {
646
+ console.error(guardError);
647
+ return 1;
623
648
  }
624
649
  try {
625
- await runInit(process.cwd());
626
- process.exit(0);
650
+ await runInit(cwd);
651
+ return 0;
627
652
  }
628
653
  catch (err) {
629
654
  const msg = err instanceof Error ? err.message : String(err);
630
655
  console.error(`Bridge API --init failed: ${msg}`);
631
- process.exit(1);
656
+ return 1;
632
657
  }
633
658
  }
634
- // ---------------------------------------------------------------------------
635
- // CLI: --upgrade
636
- // ---------------------------------------------------------------------------
637
- if (process.argv.includes("--upgrade")) {
638
- try {
639
- await stat(path.join(process.cwd(), "package.json"));
640
- }
641
- catch {
642
- console.error("Error: No package.json found in current directory.\n" +
643
- "--upgrade must be run from your project root (the directory containing package.json).");
644
- process.exit(1);
659
+ /** Run the ``--upgrade`` flow (npm install latest + re-scaffold). */
660
+ async function runUpgradeCli(cwd) {
661
+ const guardError = await ensurePackageJsonForCliCommand("--upgrade", cwd);
662
+ if (guardError) {
663
+ console.error(guardError);
664
+ return 1;
645
665
  }
646
666
  try {
647
- const cwd = process.cwd();
648
667
  const oldVersion = VERSION;
649
668
  console.log("Upgrading @bridge_gpt/mcp-server to latest...\n");
650
669
  execSync("npm i @bridge_gpt/mcp-server@latest", { stdio: "inherit" });
@@ -660,14 +679,58 @@ if (process.argv.includes("--upgrade")) {
660
679
  console.log("\nRefreshing scaffolded artifacts...\n");
661
680
  await runInit(cwd);
662
681
  console.log("\nUpgrade complete.");
663
- process.exit(0);
682
+ return 0;
664
683
  }
665
684
  catch (err) {
666
685
  const msg = err instanceof Error ? err.message : String(err);
667
686
  console.error(`Bridge API --upgrade failed: ${msg}`);
668
- process.exit(1);
687
+ return 1;
669
688
  }
670
689
  }
690
+ /**
691
+ * Route CLI subcommands before MCP server startup. Returns an exit code to
692
+ * exit the process with, or ``null`` to continue to normal MCP server startup.
693
+ *
694
+ * ``--init`` takes precedence over ``--upgrade`` (both are position-independent
695
+ * flags); ``start-tickets`` is a positional subcommand. Any other unknown,
696
+ * non-flag positional first token is rejected.
697
+ */
698
+ async function dispatchCliSubcommand(argv) {
699
+ const cwd = process.cwd();
700
+ // A positional subcommand owns the rest of argv, so e.g. `start-tickets --init`
701
+ // is a start-tickets invocation, not an --init one. Check it before the
702
+ // position-independent --init / --upgrade flag guards.
703
+ if (argv[0] === "start-tickets") {
704
+ return runStartTicketsCli(argv.slice(1));
705
+ }
706
+ // The read-only `doctor` subcommand is routed beside start-tickets, before the
707
+ // flag guards and well before MCP server construction (it never starts the server).
708
+ if (argv[0] === "doctor") {
709
+ return runDoctorCli(argv.slice(1));
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
+ }
717
+ // --init takes precedence over --upgrade; both are position-independent flags.
718
+ if (argv.includes("--init")) {
719
+ return runInitCli(cwd);
720
+ }
721
+ if (argv.includes("--upgrade")) {
722
+ return runUpgradeCli(cwd);
723
+ }
724
+ if (argv.length > 0 && !argv[0].startsWith("-")) {
725
+ console.error(`Error: Unknown subcommand '${argv[0]}'. Run with --help for usage, or omit subcommands to start the MCP server.`);
726
+ return 1;
727
+ }
728
+ return null;
729
+ }
730
+ const cliExitCode = await dispatchCliSubcommand(process.argv.slice(2));
731
+ if (cliExitCode !== null) {
732
+ process.exit(cliExitCode);
733
+ }
671
734
  // ---------------------------------------------------------------------------
672
735
  // Server
673
736
  // ---------------------------------------------------------------------------
@@ -801,7 +864,7 @@ registerTool("get_project_standards", {
801
864
  });
802
865
  registerTool("get_tickets", {
803
866
  description: "Search for and list Jira tickets from the configured project. " +
804
- "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. " +
805
868
  "All data is fetched live from Jira. Use get_ticket to retrieve full details for a specific ticket.",
806
869
  inputSchema: {
807
870
  query: z
@@ -814,6 +877,12 @@ registerTool("get_tickets", {
814
877
  .string()
815
878
  .optional()
816
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\""),
817
886
  limit: z
818
887
  .number()
819
888
  .optional()
@@ -829,12 +898,14 @@ registerTool("get_tickets", {
829
898
  .optional()
830
899
  .describe("ISO date string (YYYY-MM-DD). Only return tickets updated on or after this date"),
831
900
  },
832
- }, async ({ query, status, limit, offset, updated_since }) => {
901
+ }, async ({ query, status, labels, limit, offset, updated_since }) => {
833
902
  const params = { repo_name: REPO_NAME };
834
903
  if (query)
835
904
  params.query = query;
836
905
  if (status)
837
906
  params.status = status;
907
+ if (labels)
908
+ params.labels = labels;
838
909
  if (limit !== undefined)
839
910
  params.limit = String(limit);
840
911
  if (offset !== undefined && offset > 0)
@@ -849,7 +920,8 @@ registerTool("get_tickets", {
849
920
  registerTool("get_ticket", {
850
921
  description: "Retrieve full details for a single Jira ticket by its key. " +
851
922
  "Returns summary, status, type, assignee, reporter, description, and timestamps. " +
852
- "All data is fetched live from Jira. Use get_tickets to search/list multiple tickets.",
923
+ "All data is fetched live from Jira. Use get_tickets to search/list multiple tickets. " +
924
+ "Use get_comments to fetch comments on the ticket.",
853
925
  inputSchema: {
854
926
  ticket_number: z
855
927
  .string()
@@ -861,11 +933,29 @@ registerTool("get_ticket", {
861
933
  const text = await handleResponse(resp);
862
934
  return { content: [{ type: "text", text }] };
863
935
  });
936
+ registerTool("get_comments", {
937
+ description: "Retrieve all comments on a Jira ticket, oldest-first. " +
938
+ "Returns an array of {id, author, body, created, updated}. " +
939
+ "Comment bodies are Markdown (converted from Jira wiki markup). " +
940
+ "Use this to read what a developer or stakeholder has said on a ticket. " +
941
+ "Use add_comment to post a new comment.",
942
+ inputSchema: {
943
+ ticket_number: z
944
+ .string()
945
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123)"),
946
+ },
947
+ }, async ({ ticket_number }) => {
948
+ const url = buildGetUrl(`/tickets/${encodeURIComponent(ticket_number)}/comments`, { repo_name: REPO_NAME });
949
+ const resp = await fetch(url, { headers: GET_HEADERS });
950
+ const text = await handleResponse(resp);
951
+ return { content: [{ type: "text", text }] };
952
+ });
864
953
  registerTool("create_ticket", {
865
954
  description: "Create a new Jira ticket in the configured project. Requires either description or file_path (or both — file_path takes precedence). " +
866
955
  "Returns JSON with {ticket_key: 'PROJ-123', url: 'https://...'}. " +
867
956
  "The ticket is created immediately in Jira — confirm details with the user before calling. " +
868
- "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.",
869
959
  inputSchema: {
870
960
  summary: z.string().describe("Ticket title — keep under 100 characters"),
871
961
  description: z
@@ -895,12 +985,17 @@ registerTool("create_ticket", {
895
985
  .string()
896
986
  .optional()
897
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."),
898
993
  },
899
- }, async ({ summary, description, file_path, issue_type, priority, labels, assignee }) => {
994
+ }, async ({ summary, description, file_path, issue_type, priority, labels, assignee, parent_key }) => {
900
995
  const resolved = await resolveTextOrFile(description, file_path, "description");
901
996
  if (!resolved.ok)
902
997
  return resolved.errorResponse;
903
- 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 });
904
999
  return { content: [{ type: "text", text: text + resolved.note }] };
905
1000
  });
906
1001
  registerTool("get_plan", {
@@ -1971,6 +2066,9 @@ const VALID_CONFIG_FIELDS = [
1971
2066
  "template_correctness_standards", "style_correctness_standards",
1972
2067
  "design_principles",
1973
2068
  "post_pr_target_status", "ci_check_config", "ci_followup_config",
2069
+ "allow_mutating_smoke_ops",
2070
+ "selected_mcp_slugs",
2071
+ "base_branch",
1974
2072
  ].join(", ");
1975
2073
  registerTool("list_config_fields", {
1976
2074
  description: "List all configurable fields available for reading and updating via the Bridge API. " +
@@ -2016,18 +2114,104 @@ registerTool("update_config_field", {
2016
2114
  "Returns 400 if the field name is invalid, 404 if the repository has no configuration row yet.",
2017
2115
  inputSchema: {
2018
2116
  field_name: z.string().describe(`The configuration field to update. Valid options: ${VALID_CONFIG_FIELDS}`),
2019
- value: z.string().optional().describe("The new value for the configuration field. Provide either value or file_path, not both. " +
2020
- "Omit both value and file_path to set the field to NULL (clearing it)."),
2117
+ value: z.union([z.string(), z.boolean(), z.array(z.string())]).optional().describe("The new value for the configuration field. Provide either value or file_path, not both. " +
2118
+ "Most fields take a string; scalar boolean fields (e.g. allow_mutating_smoke_ops) take true/false. " +
2119
+ "The selected_mcp_slugs field takes a JSON array of supported MCP validation manual slug strings " +
2120
+ "(e.g. [\"b2c-commerce-developer\", \"playwright-mcp\", \"pwa-kit-mcp\"]) — pass an array of strings, " +
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'. " +
2125
+ "For string fields, omit both value and file_path to set the field to NULL (clearing it). " +
2126
+ "Scalar boolean fields are NOT NULL and have no clear/null state: omitting the value writes false " +
2127
+ "(matching the API-layer coercion), so pass true/false explicitly."),
2021
2128
  file_path: z.string().optional().describe("Path to a local file whose contents will be used as the new value. " +
2022
2129
  "Useful for large configuration values like detailed review instructions. " +
2023
- "The file must be UTF-8 encoded and under 1MB."),
2130
+ "The file must be UTF-8 encoded and under 1MB. " +
2131
+ "Not supported for scalar boolean fields like allow_mutating_smoke_ops."),
2024
2132
  },
2025
2133
  }, async ({ field_name, value, file_path }) => {
2134
+ // JSONB array config fields (e.g. selected_mcp_slugs): forward the array value
2135
+ // as JSON. Never join into a comma-delimited string — the backend expects a
2136
+ // real JSON array and validates each slug against the mcp_docs allowlist.
2137
+ const ARRAY_CONFIG_FIELDS = ["selected_mcp_slugs"];
2138
+ if (ARRAY_CONFIG_FIELDS.includes(field_name)) {
2139
+ if (file_path) {
2140
+ return {
2141
+ isError: true,
2142
+ content: [{
2143
+ type: "text",
2144
+ text: JSON.stringify({
2145
+ error: `'${field_name}' is a JSON array field; file_path updates are not supported. Pass value as an array of slug strings.`,
2146
+ }),
2147
+ }],
2148
+ };
2149
+ }
2150
+ // An omitted value clears the selection (empty array). Any defined value is
2151
+ // forwarded verbatim as JSON so the backend can validate/reject it.
2152
+ const arrayValue = value === undefined ? [] : value;
2153
+ const resp = await fetch(buildUrl(`/config-field/${encodeURIComponent(field_name)}`), {
2154
+ method: "PUT",
2155
+ headers: POST_HEADERS,
2156
+ body: JSON.stringify({ repo_name: REPO_NAME, value: arrayValue }),
2157
+ });
2158
+ const text = await handleResponse(resp);
2159
+ return { content: [{ type: "text", text }] };
2160
+ }
2161
+ // Scalar boolean config fields: reject file-path updates and normalize boolean
2162
+ // true/false and string "true"/"false" to a real boolean before persisting.
2163
+ const BOOLEAN_CONFIG_FIELDS = ["allow_mutating_smoke_ops"];
2164
+ if (BOOLEAN_CONFIG_FIELDS.includes(field_name)) {
2165
+ if (file_path) {
2166
+ return {
2167
+ isError: true,
2168
+ content: [{
2169
+ type: "text",
2170
+ text: JSON.stringify({
2171
+ error: `'${field_name}' is a scalar boolean field; file_path updates are not supported. Pass value: true or value: false.`,
2172
+ }),
2173
+ }],
2174
+ };
2175
+ }
2176
+ // NOT NULL boolean column: an omitted value writes false (no clear/null state),
2177
+ // intentionally aligned with the API-layer coercion in update_config_field_endpoint.
2178
+ let boolValue = false;
2179
+ if (typeof value === "boolean") {
2180
+ boolValue = value;
2181
+ }
2182
+ else if (typeof value === "string") {
2183
+ const normalized = value.trim().toLowerCase();
2184
+ if (normalized === "true") {
2185
+ boolValue = true;
2186
+ }
2187
+ else if (normalized === "false" || normalized === "") {
2188
+ boolValue = false;
2189
+ }
2190
+ else {
2191
+ return {
2192
+ isError: true,
2193
+ content: [{
2194
+ type: "text",
2195
+ text: JSON.stringify({
2196
+ error: `Invalid value for '${field_name}': '${value}'. Expected true or false.`,
2197
+ }),
2198
+ }],
2199
+ };
2200
+ }
2201
+ }
2202
+ const resp = await fetch(buildUrl(`/config-field/${encodeURIComponent(field_name)}`), {
2203
+ method: "PUT",
2204
+ headers: POST_HEADERS,
2205
+ body: JSON.stringify({ repo_name: REPO_NAME, value: boolValue }),
2206
+ });
2207
+ const text = await handleResponse(resp);
2208
+ return { content: [{ type: "text", text }] };
2209
+ }
2026
2210
  // Allow omitting both value and file_path to set NULL
2027
2211
  let finalValue = null;
2028
2212
  let note = "";
2029
2213
  if (value || file_path) {
2030
- const resolved = await resolveTextOrFile(value, file_path, "value");
2214
+ const resolved = await resolveTextOrFile(typeof value === "string" ? value : undefined, file_path, "value");
2031
2215
  if (!resolved.ok)
2032
2216
  return resolved.errorResponse;
2033
2217
  finalValue = resolved.text;
@@ -2635,7 +2819,7 @@ registerTool("list_pipelines", {
2635
2819
  const list = Object.entries(PIPELINES).map(([key, pipeline]) => ({
2636
2820
  name: key,
2637
2821
  description: pipeline.description ?? "",
2638
- variables: (pipeline.variables ?? []).filter((v) => v !== "docs_dir"),
2822
+ variables: (pipeline.variables ?? []).filter((v) => v !== "docs_dir" && v !== "idea_hash"),
2639
2823
  source: userPipelineKeys.has(key) ? "user" : "bundled",
2640
2824
  }));
2641
2825
  return {
@@ -2653,7 +2837,7 @@ registerTool("get_pipeline_recipe", {
2653
2837
  .string()
2654
2838
  .describe("Pipeline name (e.g. 'review-ticket', 'implement-ticket')"),
2655
2839
  variables: z
2656
- .record(z.string())
2840
+ .record(z.string(), z.string())
2657
2841
  .optional()
2658
2842
  .describe("Key-value pairs for variable substitution (e.g. { ticket_key: 'BAPI-123' })"),
2659
2843
  skip_steps: z
@@ -2704,6 +2888,11 @@ registerTool("get_pipeline_recipe", {
2704
2888
  auto_approve: auto_approve ? "true" : "",
2705
2889
  ...(variables ?? {}),
2706
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
+ }
2707
2896
  const recipe = resolveRecipe(pipelineDef, INSTRUCTIONS, mergedVariables, skip_steps, !!auto_approve);
2708
2897
  return {
2709
2898
  content: [{
@@ -2756,6 +2945,21 @@ function buildPipelineOrchestratorDeps() {
2756
2945
  toolHandlers: TOOL_HANDLERS,
2757
2946
  };
2758
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
+ }
2759
2963
  registerTool("run_pipeline", {
2760
2964
  description: "Execute a Bridge API pipeline by name. The orchestrator runs steps sequentially, " +
2761
2965
  "dispatching mcp_call steps in-process and pausing on agent_task steps with a " +
@@ -2773,7 +2977,7 @@ registerTool("run_pipeline", {
2773
2977
  .string()
2774
2978
  .describe("Pipeline name (e.g. 'review-ticket', 'implement-ticket')"),
2775
2979
  variables: z
2776
- .record(z.string())
2980
+ .record(z.string(), z.string())
2777
2981
  .optional()
2778
2982
  .describe("Key-value pairs for variable substitution (e.g. { ticket_key: 'BAPI-123' }). " +
2779
2983
  "Do NOT pass `auto_approve` here — use the top-level parameter."),
@@ -2865,6 +3069,85 @@ registerTool("delete_pipeline_run", {
2865
3069
  };
2866
3070
  });
2867
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
+ // ---------------------------------------------------------------------------
2868
3151
  // generate_decision_page
2869
3152
  // ---------------------------------------------------------------------------
2870
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
  // ---------------------------------------------------------------------------