@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/README.md +80 -0
- package/build/agent-launchers/claude.js +85 -0
- package/build/agent-launchers/index.js +17 -0
- package/build/agent-launchers/types.js +1 -0
- package/build/agent-registry.js +68 -0
- package/build/chain-orchestrator.js +1150 -0
- package/build/chain-utils.js +68 -0
- package/build/commands.generated.js +3 -1
- package/build/doctor.js +172 -0
- package/build/fetch-stub.js +139 -0
- package/build/index.js +321 -38
- package/build/pipeline-orchestrator.js +57 -0
- package/build/pipelines.generated.js +133 -4
- package/build/schedule-run.js +951 -0
- package/build/schedule-store.js +132 -0
- package/build/scheduler-backends/at-fallback.js +144 -0
- package/build/scheduler-backends/escaping.js +113 -0
- package/build/scheduler-backends/index.js +72 -0
- package/build/scheduler-backends/launchd.js +216 -0
- package/build/scheduler-backends/systemd-user.js +237 -0
- package/build/scheduler-backends/task-scheduler.js +219 -0
- package/build/scheduler-backends/types.js +23 -0
- package/build/start-tickets-prereqs.js +346 -0
- package/build/start-tickets.js +1270 -0
- package/build/version.generated.js +1 -1
- package/package.json +9 -7
- package/pipelines/full-automation.json +47 -0
- package/pipelines/idea-to-ticket.json +71 -0
- package/smoke-test/SMOKE-TEST.md +509 -0
- package/smoke-test/smoke-test-mcp.md +23 -0
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
|
|
619
|
+
// CLI subcommand dispatch
|
|
614
620
|
// ---------------------------------------------------------------------------
|
|
615
|
-
|
|
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(
|
|
634
|
+
await stat(path.join(cwd, "package.json"));
|
|
635
|
+
return null;
|
|
618
636
|
}
|
|
619
637
|
catch {
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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(
|
|
626
|
-
|
|
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
|
-
|
|
656
|
+
return 1;
|
|
632
657
|
}
|
|
633
658
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
if (
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
// ---------------------------------------------------------------------------
|