@bridge_gpt/mcp-server 0.1.16 → 0.2.0
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 +333 -162
- package/build/agent-capabilities/cli.js +152 -0
- package/build/agent-capabilities/default-deps.js +45 -0
- package/build/agent-capabilities/probe-context.js +111 -0
- package/build/agent-capabilities/probes.js +278 -0
- package/build/agent-capabilities/reporter.js +50 -0
- package/build/agent-capabilities/runner.js +56 -0
- package/build/agent-capabilities/types.js +10 -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/agents.generated.js +1 -1
- package/build/brainstorm-files.js +89 -0
- package/build/bridge-config.js +404 -0
- package/build/chain-orchestrator.js +1364 -0
- package/build/chain-utils.js +68 -0
- package/build/commands.generated.js +5 -3
- package/build/credential-materialization.js +128 -0
- package/build/credential-store.js +232 -0
- package/build/decision-page-schema.js +39 -6
- package/build/decision-page-template.js +54 -18
- package/build/doctor.js +18 -2
- package/build/fetch-stub.js +139 -0
- package/build/git-ignore-utils.js +63 -0
- package/build/index.js +1623 -546
- package/build/mcp-invoke.js +417 -0
- package/build/mcp-provisioning.js +249 -0
- package/build/mcp-registration-doctor.js +96 -0
- package/build/pipeline-orchestrator.js +66 -1
- package/build/pipeline-utils.js +33 -0
- package/build/pipelines.generated.js +165 -5
- 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 +90 -1
- package/build/start-tickets.js +222 -70
- package/build/third-party-mcp-targets.js +75 -0
- package/build/version.generated.js +1 -1
- package/package.json +8 -8
- package/pipelines/full-automation.json +49 -0
- package/pipelines/idea-to-ticket.json +71 -0
- package/pipelines/implement-ticket.json +28 -2
- package/smoke-test/SMOKE-TEST.md +511 -0
- package/smoke-test/smoke-test-mcp.md +23 -0
package/build/index.js
CHANGED
|
@@ -14,9 +14,11 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
14
14
|
import { z } from "zod";
|
|
15
15
|
import { writeFile, mkdir, readFile, stat } from "fs/promises";
|
|
16
16
|
import path from "path";
|
|
17
|
+
import os from "os";
|
|
18
|
+
import { fileURLToPath } from "url";
|
|
17
19
|
import { execSync } from "child_process";
|
|
18
20
|
import { createRequire } from "module";
|
|
19
|
-
import { PIPELINES as BUNDLED_PIPELINES, INSTRUCTIONS as BUNDLED_INSTRUCTIONS } from "./pipelines.generated.js";
|
|
21
|
+
import { PIPELINES as BUNDLED_PIPELINES, INSTRUCTIONS as BUNDLED_INSTRUCTIONS, CHAIN_RECIPES } from "./pipelines.generated.js";
|
|
20
22
|
import { COMMANDS } from "./commands.generated.js";
|
|
21
23
|
import { AGENTS } from "./agents.generated.js";
|
|
22
24
|
import { VERSION } from "./version.generated.js";
|
|
@@ -25,9 +27,17 @@ import { reconstructAgentMarkdown, translateAgentToCopilot } from "./agent-utils
|
|
|
25
27
|
import { resolveRecipe, loadCustomPipelines } from "./pipeline-utils.js";
|
|
26
28
|
import { runStartTicketsCli } from "./start-tickets.js";
|
|
27
29
|
import { runDoctorCli } from "./doctor.js";
|
|
30
|
+
import { runScheduleRunCli } from "./schedule-run.js";
|
|
31
|
+
import { runMcpInvokeCli } from "./mcp-invoke.js";
|
|
32
|
+
import { runAgentCapabilitiesCli } from "./agent-capabilities/cli.js";
|
|
33
|
+
import { validateRepoName } from "./bridge-config.js";
|
|
34
|
+
import { ensureGitignored as ensureGitignoredShared } from "./git-ignore-utils.js";
|
|
35
|
+
import { resolveBapiCredentials } from "./credential-store.js";
|
|
28
36
|
import { generateDecisionPageHtml } from "./decision-page-template.js";
|
|
29
37
|
import { DecisionPageInputShape } from "./decision-page-schema.js";
|
|
30
|
-
import {
|
|
38
|
+
import { slugify, saveBrainstormResultsToDir, } from "./brainstorm-files.js";
|
|
39
|
+
import { runPipeline, resumePipeline, listPipelineRuns, deletePipelineRun, deriveIdeaHash, } from "./pipeline-orchestrator.js";
|
|
40
|
+
import { runFullAutomation, resumeFullAutomation, } from "./chain-orchestrator.js";
|
|
31
41
|
// Mutable pipeline/instruction state — starts with bundled, merged with user at startup
|
|
32
42
|
const PIPELINES = { ...BUNDLED_PIPELINES };
|
|
33
43
|
const INSTRUCTIONS = { ...BUNDLED_INSTRUCTIONS };
|
|
@@ -37,15 +47,130 @@ let userPipelineKeys = new Set();
|
|
|
37
47
|
// ---------------------------------------------------------------------------
|
|
38
48
|
const BASE_URL = process.env.BAPI_BASE_URL ?? "https://bridgegpt-api.com";
|
|
39
49
|
const REPO_NAME = process.env.BAPI_REPO_NAME ?? "";
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Resolved-once credential + path accessors (BAPI-338)
|
|
52
|
+
//
|
|
53
|
+
// The API key, auth headers, project root, docs dir, and pipelines dir are NO
|
|
54
|
+
// LONGER eager module-load constants. They are resolved lazily on first use and
|
|
55
|
+
// cached, so:
|
|
56
|
+
// - the credential store is read only when BAPI_API_KEY is absent from env;
|
|
57
|
+
// - the project root can be resolved from MCP `roots/list`, which is only
|
|
58
|
+
// available AFTER the server connection completes (see the connected
|
|
59
|
+
// marker set at the entry point).
|
|
60
|
+
// Env always wins over the store / roots-list, preserving existing setups.
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
let resolvedApiKeyPromise;
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the Bridge API key env-first, then from the home-dir credential store
|
|
65
|
+
* (`bapi:<repoName>`). Resolved once and cached. Returns an empty string on any
|
|
66
|
+
* failure (a generic auth-failure path) and never logs secret material.
|
|
67
|
+
*/
|
|
68
|
+
async function getResolvedApiKey() {
|
|
69
|
+
if (!resolvedApiKeyPromise) {
|
|
70
|
+
resolvedApiKeyPromise = (async () => {
|
|
71
|
+
try {
|
|
72
|
+
const result = await resolveBapiCredentials(REPO_NAME, {
|
|
73
|
+
env: process.env,
|
|
74
|
+
homedir: os.homedir,
|
|
75
|
+
platform: process.platform,
|
|
76
|
+
readFile: (p) => readFile(p, "utf-8"),
|
|
77
|
+
stat: (p) => stat(p),
|
|
78
|
+
});
|
|
79
|
+
return result.ok ? result.credentials.apiKey : "";
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return "";
|
|
83
|
+
}
|
|
84
|
+
})();
|
|
85
|
+
}
|
|
86
|
+
return resolvedApiKeyPromise;
|
|
87
|
+
}
|
|
88
|
+
/** GET auth headers, with the API key resolved once via {@link getResolvedApiKey}. */
|
|
89
|
+
async function getGetHeaders() {
|
|
90
|
+
return { "X-API-Key": await getResolvedApiKey() };
|
|
91
|
+
}
|
|
92
|
+
/** POST auth headers (API key + JSON content type). */
|
|
93
|
+
async function getPostHeaders() {
|
|
94
|
+
return { "X-API-Key": await getResolvedApiKey(), "Content-Type": "application/json" };
|
|
95
|
+
}
|
|
96
|
+
/** Set true immediately after the server connection (transport) completes. */
|
|
97
|
+
let serverConnected = false;
|
|
98
|
+
/**
|
|
99
|
+
* Query MCP `roots/list` through the connected server and return the first
|
|
100
|
+
* usable `file://` root path, or null. Only attempted after the server has
|
|
101
|
+
* connected; all failures are caught and yield null (the caller falls through).
|
|
102
|
+
*/
|
|
103
|
+
async function resolveProjectRootFromRootsList() {
|
|
104
|
+
if (!serverConnected)
|
|
105
|
+
return null;
|
|
106
|
+
try {
|
|
107
|
+
const result = await server.server.listRoots();
|
|
108
|
+
const roots = Array.isArray(result?.roots) ? result.roots : [];
|
|
109
|
+
for (const root of roots) {
|
|
110
|
+
const uri = root?.uri;
|
|
111
|
+
if (typeof uri === "string" && uri.startsWith("file://")) {
|
|
112
|
+
try {
|
|
113
|
+
return fileURLToPath(uri);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
/* not a usable file URI — keep scanning */
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
let projectRootPromise;
|
|
127
|
+
/**
|
|
128
|
+
* Resolve the project root once, in the exact order:
|
|
129
|
+
* 1. `BAPI_PROJECT_ROOT` env (always wins),
|
|
130
|
+
* 2. connected MCP `roots/list`,
|
|
131
|
+
* 3. `CLAUDE_PROJECT_DIR` env,
|
|
132
|
+
* 4. `process.cwd()`.
|
|
133
|
+
*
|
|
134
|
+
* INVARIANT: the FIRST call must happen inside a tool handler (i.e. AFTER the
|
|
135
|
+
* server connects), so the `roots/list` branch is reachable. This holds today —
|
|
136
|
+
* custom-pipeline loading is deferred and `checkCiConfigAndDisablePoll()` only
|
|
137
|
+
* resolves headers, not paths. If any future module-load code path calls this
|
|
138
|
+
* (or `getDocsDir()` / `getPipelinesDir()`) before connect, it would permanently
|
|
139
|
+
* cache a `cwd` / `CLAUDE_PROJECT_DIR` fallback even when a `file://` root exists.
|
|
140
|
+
*/
|
|
141
|
+
async function getProjectRoot() {
|
|
142
|
+
if (!projectRootPromise) {
|
|
143
|
+
projectRootPromise = (async () => {
|
|
144
|
+
const explicit = (process.env.BAPI_PROJECT_ROOT ?? "").trim();
|
|
145
|
+
if (explicit.length > 0)
|
|
146
|
+
return explicit;
|
|
147
|
+
const fromRoots = await resolveProjectRootFromRootsList();
|
|
148
|
+
if (fromRoots && fromRoots.length > 0)
|
|
149
|
+
return fromRoots;
|
|
150
|
+
const claudeDir = (process.env.CLAUDE_PROJECT_DIR ?? "").trim();
|
|
151
|
+
if (claudeDir.length > 0)
|
|
152
|
+
return claudeDir;
|
|
153
|
+
return process.cwd();
|
|
154
|
+
})();
|
|
155
|
+
}
|
|
156
|
+
return projectRootPromise;
|
|
157
|
+
}
|
|
158
|
+
let docsDirPromise;
|
|
159
|
+
/** Resolve `BAPI_DOCS_DIR` (default `docs/tmp`) against the project root, once. */
|
|
160
|
+
async function getDocsDir() {
|
|
161
|
+
if (!docsDirPromise) {
|
|
162
|
+
docsDirPromise = (async () => path.resolve(await getProjectRoot(), process.env.BAPI_DOCS_DIR ?? "docs/tmp"))();
|
|
163
|
+
}
|
|
164
|
+
return docsDirPromise;
|
|
165
|
+
}
|
|
166
|
+
let pipelinesDirPromise;
|
|
167
|
+
/** Resolve `BAPI_PIPELINES_DIR` (default `.bridge/pipelines`) against the root, once. */
|
|
168
|
+
async function getPipelinesDir() {
|
|
169
|
+
if (!pipelinesDirPromise) {
|
|
170
|
+
pipelinesDirPromise = (async () => path.resolve(await getProjectRoot(), process.env.BAPI_PIPELINES_DIR ?? ".bridge/pipelines"))();
|
|
171
|
+
}
|
|
172
|
+
return pipelinesDirPromise;
|
|
173
|
+
}
|
|
49
174
|
// ---------------------------------------------------------------------------
|
|
50
175
|
// Helpers
|
|
51
176
|
// ---------------------------------------------------------------------------
|
|
@@ -62,18 +187,32 @@ function buildGetUrl(path, params) {
|
|
|
62
187
|
}
|
|
63
188
|
return url.toString();
|
|
64
189
|
}
|
|
65
|
-
function getDocsPath(subdir) {
|
|
66
|
-
return path.join(
|
|
190
|
+
async function getDocsPath(subdir) {
|
|
191
|
+
return path.join(await getDocsDir(), subdir);
|
|
67
192
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
193
|
+
/**
|
|
194
|
+
* Merge custom user pipelines (from the resolved pipelines dir) into PIPELINES /
|
|
195
|
+
* INSTRUCTIONS / userPipelineKeys exactly once. Deferred (not run at module load)
|
|
196
|
+
* so it can await the project-root resolution that depends on `roots/list`.
|
|
197
|
+
*/
|
|
198
|
+
let customPipelinesPromise;
|
|
199
|
+
async function ensureCustomPipelinesLoaded() {
|
|
200
|
+
if (!customPipelinesPromise) {
|
|
201
|
+
customPipelinesPromise = (async () => {
|
|
202
|
+
const pipelinesDir = await getPipelinesDir();
|
|
203
|
+
const instructionsDir = path.join(path.dirname(pipelinesDir), "instructions");
|
|
204
|
+
const customResult = await loadCustomPipelines(pipelinesDir, instructionsDir, BUNDLED_INSTRUCTIONS);
|
|
205
|
+
for (const [key, pipeline] of Object.entries(customResult.pipelines)) {
|
|
206
|
+
if (key in BUNDLED_PIPELINES) {
|
|
207
|
+
console.error(`Warning: user pipeline "${key}" overrides bundled pipeline.`);
|
|
208
|
+
}
|
|
209
|
+
PIPELINES[key] = pipeline;
|
|
210
|
+
}
|
|
211
|
+
Object.assign(INSTRUCTIONS, customResult.instructions);
|
|
212
|
+
userPipelineKeys = customResult.userPipelineKeys;
|
|
213
|
+
})();
|
|
214
|
+
}
|
|
215
|
+
return customPipelinesPromise;
|
|
77
216
|
}
|
|
78
217
|
const ERROR_CODES = {
|
|
79
218
|
400: "BAD_REQUEST",
|
|
@@ -102,6 +241,29 @@ async function handleResponse(resp) {
|
|
|
102
241
|
let message = rawText;
|
|
103
242
|
try {
|
|
104
243
|
const parsed = JSON.parse(rawText);
|
|
244
|
+
if (parsed.detail !== null &&
|
|
245
|
+
typeof parsed.detail === "object" &&
|
|
246
|
+
!Array.isArray(parsed.detail)) {
|
|
247
|
+
// Structured FastAPI HTTPException(detail={...}): preserve fields like
|
|
248
|
+
// error_kind and remediation at the top level so agents can read them
|
|
249
|
+
// natively instead of an opaque stringified blob.
|
|
250
|
+
const detail = parsed.detail;
|
|
251
|
+
if (typeof detail.message === "string") {
|
|
252
|
+
message = detail.message;
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
message = JSON.stringify(detail);
|
|
256
|
+
}
|
|
257
|
+
// Spread detail FIRST so the relay's own error code / HTTP status /
|
|
258
|
+
// message always win — a future detail object that happens to carry an
|
|
259
|
+
// `error`/`status`/`message` key cannot clobber the relay envelope.
|
|
260
|
+
return JSON.stringify({
|
|
261
|
+
...detail,
|
|
262
|
+
error: errorCode,
|
|
263
|
+
status: resp.status,
|
|
264
|
+
message,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
105
267
|
if (parsed.detail) {
|
|
106
268
|
message = typeof parsed.detail === "string" ? parsed.detail : JSON.stringify(parsed.detail);
|
|
107
269
|
}
|
|
@@ -127,9 +289,11 @@ async function createTicketRequest(params) {
|
|
|
127
289
|
payload.labels = params.labels;
|
|
128
290
|
if (params.assignee)
|
|
129
291
|
payload.assignee = params.assignee;
|
|
292
|
+
if (params.parent_key)
|
|
293
|
+
payload.parent_key = params.parent_key;
|
|
130
294
|
const resp = await fetch(buildUrl("/create-ticket"), {
|
|
131
295
|
method: "POST",
|
|
132
|
-
headers:
|
|
296
|
+
headers: await getPostHeaders(),
|
|
133
297
|
body: JSON.stringify(payload),
|
|
134
298
|
});
|
|
135
299
|
return handleResponse(resp);
|
|
@@ -145,6 +309,100 @@ async function saveLocally(dir, filename, content) {
|
|
|
145
309
|
return `\n\n---\nNote: Failed to save file to ${filePath}: ${writeErr}`;
|
|
146
310
|
}
|
|
147
311
|
}
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
// Heavy-read truncate-and-save helpers (BAPI-342)
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
//
|
|
316
|
+
// Four heavy read tools (get_tickets, get_ticket, get_comments,
|
|
317
|
+
// list_attachments) can return very large JSON payloads. To avoid flooding the
|
|
318
|
+
// agent's context, when a successful payload exceeds MAX_INLINE_TEXT_LENGTH the
|
|
319
|
+
// FULL payload is saved to disk first and only then is a truncated,
|
|
320
|
+
// markdown-fenced inline preview returned. Nothing is lost: if the save fails
|
|
321
|
+
// (or the target escapes the docs directory) we fall back to returning the
|
|
322
|
+
// complete untruncated payload with a warning rather than dropping data.
|
|
323
|
+
// Shared with download_attachment, which uses MAX_INLINE_TEXT_LENGTH directly.
|
|
324
|
+
const MAX_INLINE_TEXT_LENGTH = 50_000;
|
|
325
|
+
// Filesystem-safe ISO timestamp (no ":" or "." which are awkward in filenames).
|
|
326
|
+
function safeTimestampForFilename() {
|
|
327
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
328
|
+
}
|
|
329
|
+
// Sanitize an untrusted ticket number into a single safe filename segment.
|
|
330
|
+
// Preserves readable keys like "BAPI-123" while stripping path separators and
|
|
331
|
+
// other unsafe characters; falls back to "ticket" when nothing usable remains.
|
|
332
|
+
function safeTicketFileSegment(ticketNumber) {
|
|
333
|
+
const base = path.basename(ticketNumber.trim());
|
|
334
|
+
const cleaned = base
|
|
335
|
+
.replace(/[^A-Za-z0-9_-]/g, "-")
|
|
336
|
+
.replace(/-+/g, "-")
|
|
337
|
+
.replace(/^-+|-+$/g, "");
|
|
338
|
+
return cleaned || "ticket";
|
|
339
|
+
}
|
|
340
|
+
// Mirror the download_attachment containment check: a resolved save target must
|
|
341
|
+
// live strictly inside the resolved directory. Prevents traversal escapes from
|
|
342
|
+
// a crafted filename even though saveLocally does a bare path.join.
|
|
343
|
+
function isContainedSaveTarget(dir, filename) {
|
|
344
|
+
const resolvedDir = path.resolve(dir);
|
|
345
|
+
const target = path.resolve(resolvedDir, filename);
|
|
346
|
+
return target.startsWith(resolvedDir + path.sep);
|
|
347
|
+
}
|
|
348
|
+
// saveLocally returns either a "Saved to <path>" note or a "Failed to save"
|
|
349
|
+
// note (it never throws). A successful save is identified by the "Saved to "
|
|
350
|
+
// marker.
|
|
351
|
+
function saveLocallySucceeded(note) {
|
|
352
|
+
return note.includes("Saved to ");
|
|
353
|
+
}
|
|
354
|
+
// Save-before-truncate for heavy read payloads. Returns `text` unchanged when
|
|
355
|
+
// it is within the inline limit. Otherwise it saves the full payload first and
|
|
356
|
+
// only truncates after a confirmed, contained save; on any containment or save
|
|
357
|
+
// failure it returns the FULL untruncated text plus a warning so nothing is
|
|
358
|
+
// lost. Returns JSON inside a markdown code fence so a truncated mid-string
|
|
359
|
+
// slice is never mistaken for parseable JSON.
|
|
360
|
+
async function truncateAndSaveIfNeeded(text, dir, filename) {
|
|
361
|
+
if (text.length <= MAX_INLINE_TEXT_LENGTH) {
|
|
362
|
+
return text;
|
|
363
|
+
}
|
|
364
|
+
if (!isContainedSaveTarget(dir, filename)) {
|
|
365
|
+
return (text +
|
|
366
|
+
"\n\n---\nWarning: response was NOT truncated because the save target " +
|
|
367
|
+
`was rejected as outside the docs directory (dir=${dir}, filename=${filename}).`);
|
|
368
|
+
}
|
|
369
|
+
const note = await saveLocally(dir, filename, text);
|
|
370
|
+
if (!saveLocallySucceeded(note)) {
|
|
371
|
+
return (text +
|
|
372
|
+
note +
|
|
373
|
+
"\n\nWarning: response was NOT truncated because the full payload could not be saved.");
|
|
374
|
+
}
|
|
375
|
+
const truncated = text.slice(0, MAX_INLINE_TEXT_LENGTH);
|
|
376
|
+
return ("[Response truncated. Full response saved locally.]\n\n" +
|
|
377
|
+
"```json\n" +
|
|
378
|
+
truncated +
|
|
379
|
+
"\n```" +
|
|
380
|
+
note);
|
|
381
|
+
}
|
|
382
|
+
// Build a stable, readable, collision-resistant filename for an oversized
|
|
383
|
+
// get_tickets search payload. Includes the active filters and pagination so
|
|
384
|
+
// distinct search pages do not collide, plus a timestamp; falls back to "all"
|
|
385
|
+
// when no filter produces a slug.
|
|
386
|
+
function buildTicketsSearchFilename(params) {
|
|
387
|
+
const parts = [];
|
|
388
|
+
if (params.query)
|
|
389
|
+
parts.push(params.query);
|
|
390
|
+
if (params.status)
|
|
391
|
+
parts.push(params.status);
|
|
392
|
+
const labels = Array.isArray(params.labels)
|
|
393
|
+
? params.labels.join("-")
|
|
394
|
+
: params.labels;
|
|
395
|
+
if (labels)
|
|
396
|
+
parts.push(labels);
|
|
397
|
+
if (params.updated_since)
|
|
398
|
+
parts.push(`since-${params.updated_since}`);
|
|
399
|
+
if (params.limit !== undefined)
|
|
400
|
+
parts.push(`limit-${params.limit}`);
|
|
401
|
+
if (params.offset !== undefined)
|
|
402
|
+
parts.push(`offset-${params.offset}`);
|
|
403
|
+
const slug = slugify(parts.join("-")) || "all";
|
|
404
|
+
return `search-${slug}-${safeTimestampForFilename()}.json`;
|
|
405
|
+
}
|
|
148
406
|
async function resolveTextOrFile(textValue, filePath, textLabel) {
|
|
149
407
|
if (!filePath && !textValue) {
|
|
150
408
|
return {
|
|
@@ -205,7 +463,7 @@ async function pollForResult(getUrl, timeoutMs, label) {
|
|
|
205
463
|
};
|
|
206
464
|
}
|
|
207
465
|
console.error(`${label} in progress... (elapsed: ${elapsed}s)`);
|
|
208
|
-
const resp = await fetch(getUrl, { headers:
|
|
466
|
+
const resp = await fetch(getUrl, { headers: await getGetHeaders() });
|
|
209
467
|
if (resp.status === 404 || resp.status === 202) {
|
|
210
468
|
await resp.text();
|
|
211
469
|
}
|
|
@@ -219,36 +477,311 @@ async function pollForResult(getUrl, timeoutMs, label) {
|
|
|
219
477
|
}
|
|
220
478
|
}
|
|
221
479
|
}
|
|
480
|
+
const TICKET_ARTIFACTS = {
|
|
481
|
+
plan: {
|
|
482
|
+
kind: "single",
|
|
483
|
+
generateEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/generate-plan`,
|
|
484
|
+
getEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/plan`,
|
|
485
|
+
saveSubdir: "plans",
|
|
486
|
+
filename: (n) => `${n}-plan.md`,
|
|
487
|
+
requestErrorPrefix: "Failed to request plan generation: ",
|
|
488
|
+
confirmationText: (n) => `Plan generation requested for ${n}. ` +
|
|
489
|
+
`Processing typically takes 1-5 minutes. ` +
|
|
490
|
+
`Use get_plan with ticket_number "${n}" to retrieve the plan once processing completes.`,
|
|
491
|
+
pollLabel: (n) => `Plan generation for ${n}`,
|
|
492
|
+
},
|
|
493
|
+
architecture: {
|
|
494
|
+
kind: "single",
|
|
495
|
+
generateEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/generate-architecture`,
|
|
496
|
+
getEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/architecture-plan`,
|
|
497
|
+
saveSubdir: "architecture",
|
|
498
|
+
filename: (n) => `${n}-architecture-plan.md`,
|
|
499
|
+
requestErrorPrefix: "Failed to request architecture generation: ",
|
|
500
|
+
confirmationText: (n) => `Architecture generation requested for ${n}. ` +
|
|
501
|
+
`Processing typically takes 2-4 minutes. ` +
|
|
502
|
+
`Use get_architecture with ticket_number "${n}" to retrieve the architecture plan once processing completes.`,
|
|
503
|
+
pollLabel: (n) => `Architecture generation for ${n}`,
|
|
504
|
+
},
|
|
505
|
+
clarifying_questions: {
|
|
506
|
+
kind: "single",
|
|
507
|
+
generateEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/generate-clarifying-questions`,
|
|
508
|
+
getEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/clarifying-questions`,
|
|
509
|
+
saveSubdir: "clarifying-questions",
|
|
510
|
+
filename: (n) => `${n}-clarifying-questions.md`,
|
|
511
|
+
requestErrorPrefix: "Failed to request clarifying questions: ",
|
|
512
|
+
confirmationText: (n) => `Clarifying questions requested for ${n}. ` +
|
|
513
|
+
`Processing typically takes 1-5 minutes. ` +
|
|
514
|
+
`Use get_clarifying_questions with ticket_number "${n}" to retrieve the results once processing completes.`,
|
|
515
|
+
pollLabel: (n) => `Clarifying questions for ${n}`,
|
|
516
|
+
},
|
|
517
|
+
ticket_critique: {
|
|
518
|
+
kind: "single",
|
|
519
|
+
generateEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/generate-ticket-critique`,
|
|
520
|
+
getEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/ticket-critique`,
|
|
521
|
+
saveSubdir: "ticket-critiques",
|
|
522
|
+
filename: (n) => `${n}-ticket-quality-critique.md`,
|
|
523
|
+
requestErrorPrefix: "Failed to request ticket critique: ",
|
|
524
|
+
confirmationText: (n) => `Ticket critique requested for ${n}. ` +
|
|
525
|
+
`Processing typically takes 1-5 minutes. ` +
|
|
526
|
+
`Use get_ticket_critique with ticket_number "${n}" to retrieve the results once processing completes.`,
|
|
527
|
+
pollLabel: (n) => `Ticket critique for ${n}`,
|
|
528
|
+
},
|
|
529
|
+
reimplement_context: {
|
|
530
|
+
kind: "single",
|
|
531
|
+
generateEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/request-reimplement-context`,
|
|
532
|
+
getEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/reimplement-context`,
|
|
533
|
+
saveSubdir: "reimplementations",
|
|
534
|
+
filename: (n) => `${n}-context.md`,
|
|
535
|
+
requestErrorPrefix: "Failed to request reimplement context: ",
|
|
536
|
+
confirmationText: (n) => `Reimplement context processing requested for ${n}. ` +
|
|
537
|
+
`Processing typically takes 1-2 minutes. ` +
|
|
538
|
+
`Use get_reimplement_context with ticket_number "${n}" to retrieve the results once processing completes.`,
|
|
539
|
+
pollLabel: (n) => `Reimplement context for ${n}`,
|
|
540
|
+
},
|
|
541
|
+
ticket_review: {
|
|
542
|
+
kind: "review",
|
|
543
|
+
generateEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/generate-ticket-review`,
|
|
544
|
+
getEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/ticket-review`,
|
|
545
|
+
requestErrorPrefix: "Failed to request ticket review: ",
|
|
546
|
+
confirmationText: (n) => `Combined ticket review requested for ${n}. ` +
|
|
547
|
+
`Processing typically takes 2-6 minutes. ` +
|
|
548
|
+
`Use get_clarifying_questions and get_ticket_critique with ticket_number "${n}" to retrieve the two documents once processing completes.`,
|
|
549
|
+
pollLabel: (n) => `Ticket review for ${n}`,
|
|
550
|
+
clarifySaveSubdir: "clarifying-questions",
|
|
551
|
+
critiqueSaveSubdir: "ticket-critiques",
|
|
552
|
+
},
|
|
553
|
+
};
|
|
554
|
+
// Build the shared POST body for an artifact generate request. Preserves the
|
|
555
|
+
// per-tool provider routing exactly: a trimmed `second_opinion` becomes
|
|
556
|
+
// `provider_override` and takes precedence; otherwise a trimmed `provider`
|
|
557
|
+
// becomes `provider`. Blank values are omitted after trimming.
|
|
558
|
+
function buildTicketArtifactRequestBody(args) {
|
|
559
|
+
const body = { repo_name: REPO_NAME };
|
|
560
|
+
const trimmedSecondOpinion = args.second_opinion?.trim();
|
|
561
|
+
if (trimmedSecondOpinion) {
|
|
562
|
+
body.provider_override = trimmedSecondOpinion;
|
|
563
|
+
}
|
|
564
|
+
const trimmedProvider = args.provider?.trim();
|
|
565
|
+
if (trimmedProvider && !trimmedSecondOpinion) {
|
|
566
|
+
body.provider = trimmedProvider;
|
|
567
|
+
}
|
|
568
|
+
return body;
|
|
569
|
+
}
|
|
570
|
+
// Resolve the docs save directory for an artifact subdir. Implemented as an
|
|
571
|
+
// explicit switch with literal getDocsPath(...) calls so the existing static
|
|
572
|
+
// source-text invariants (literal getDocsPath subdir strings) still hold.
|
|
573
|
+
async function getTicketArtifactDocsPath(subdir) {
|
|
574
|
+
switch (subdir) {
|
|
575
|
+
case "plans":
|
|
576
|
+
return getDocsPath("plans");
|
|
577
|
+
case "architecture":
|
|
578
|
+
return getDocsPath("architecture");
|
|
579
|
+
case "clarifying-questions":
|
|
580
|
+
return getDocsPath("clarifying-questions");
|
|
581
|
+
case "ticket-critiques":
|
|
582
|
+
return getDocsPath("ticket-critiques");
|
|
583
|
+
case "reimplementations":
|
|
584
|
+
return getDocsPath("reimplementations");
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
// Shared request flow for the five single-artifact request_* tools: POST the
|
|
588
|
+
// generate endpoint, return the per-tool error prefix on a non-OK POST, and on
|
|
589
|
+
// wait_for_result poll the get endpoint (900_000 ms) and optionally save.
|
|
590
|
+
async function requestTicketArtifact(type, args) {
|
|
591
|
+
const config = TICKET_ARTIFACTS[type];
|
|
592
|
+
const resp = await fetch(buildUrl(config.generateEndpoint(args.ticket_number)), {
|
|
593
|
+
method: "POST",
|
|
594
|
+
headers: await getPostHeaders(),
|
|
595
|
+
body: JSON.stringify(buildTicketArtifactRequestBody(args)),
|
|
596
|
+
});
|
|
597
|
+
if (!resp.ok) {
|
|
598
|
+
const errorText = await handleResponse(resp);
|
|
599
|
+
return {
|
|
600
|
+
content: [{ type: "text", text: `${config.requestErrorPrefix}${errorText}` }],
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
if (args.wait_for_result) {
|
|
604
|
+
const getUrl = buildGetUrl(config.getEndpoint(args.ticket_number), { repo_name: REPO_NAME });
|
|
605
|
+
const result = await pollForResult(getUrl, 900_000, config.pollLabel(args.ticket_number));
|
|
606
|
+
if (!result.ok) {
|
|
607
|
+
return { content: [{ type: "text", text: result.text }] };
|
|
608
|
+
}
|
|
609
|
+
let text = result.text;
|
|
610
|
+
if (args.save_locally) {
|
|
611
|
+
const note = await saveLocally(await getTicketArtifactDocsPath(config.saveSubdir), config.filename(args.ticket_number), text);
|
|
612
|
+
text += note;
|
|
613
|
+
}
|
|
614
|
+
return { content: [{ type: "text", text }] };
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
content: [{ type: "text", text: config.confirmationText(args.ticket_number) }],
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
// Shared GET/save flow for the five single-artifact get_* tools. Normal gets
|
|
621
|
+
// save only on `resp.ok && save_locally`. reimplement_context is asymmetric: it
|
|
622
|
+
// short-circuits a 404 with a custom NOT_FOUND envelope (without leaking the
|
|
623
|
+
// backend body) and otherwise saves on ANY non-404 response when save_locally.
|
|
624
|
+
async function getTicketArtifact(type, args) {
|
|
625
|
+
const config = TICKET_ARTIFACTS[type];
|
|
626
|
+
const resp = await fetch(buildGetUrl(config.getEndpoint(args.ticket_number), { repo_name: REPO_NAME }), { headers: await getGetHeaders() });
|
|
627
|
+
if (type === "reimplement_context") {
|
|
628
|
+
if (resp.status === 404) {
|
|
629
|
+
return {
|
|
630
|
+
content: [{
|
|
631
|
+
type: "text",
|
|
632
|
+
text: JSON.stringify({
|
|
633
|
+
error: "NOT_FOUND",
|
|
634
|
+
message: `Reimplement context for ${args.ticket_number} is not yet available. ` +
|
|
635
|
+
`Processing may still be in progress. Try again in a moment, ` +
|
|
636
|
+
`or call request_reimplement_context to trigger processing.`,
|
|
637
|
+
}),
|
|
638
|
+
}],
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
const text = await handleResponse(resp);
|
|
642
|
+
if (args.save_locally) {
|
|
643
|
+
const note = await saveLocally(await getTicketArtifactDocsPath(config.saveSubdir), config.filename(args.ticket_number), text);
|
|
644
|
+
return { content: [{ type: "text", text: text + note }] };
|
|
645
|
+
}
|
|
646
|
+
return { content: [{ type: "text", text }] };
|
|
647
|
+
}
|
|
648
|
+
const ok = resp.ok;
|
|
649
|
+
let text = await handleResponse(resp);
|
|
650
|
+
if (ok && args.save_locally) {
|
|
651
|
+
const note = await saveLocally(await getTicketArtifactDocsPath(config.saveSubdir), config.filename(args.ticket_number), text);
|
|
652
|
+
text += note;
|
|
653
|
+
}
|
|
654
|
+
return { content: [{ type: "text", text }] };
|
|
655
|
+
}
|
|
656
|
+
// Bespoke combined ticket-review flow. The backend GET returns a JSON envelope
|
|
657
|
+
// ({ clarify, critique }) rather than a single artifact, so this is kept
|
|
658
|
+
// separate from requestTicketArtifact: it fans the envelope out into two
|
|
659
|
+
// markdown parts joined by "\n\n---\n\n" and saves each sub-doc (when present
|
|
660
|
+
// with a doc_type) into its own subdir using a doc_type-derived filename.
|
|
661
|
+
async function requestTicketReview(args) {
|
|
662
|
+
const config = TICKET_ARTIFACTS.ticket_review;
|
|
663
|
+
const resp = await fetch(buildUrl(config.generateEndpoint(args.ticket_number)), {
|
|
664
|
+
method: "POST",
|
|
665
|
+
headers: await getPostHeaders(),
|
|
666
|
+
body: JSON.stringify(buildTicketArtifactRequestBody(args)),
|
|
667
|
+
});
|
|
668
|
+
if (!resp.ok) {
|
|
669
|
+
const errorText = await handleResponse(resp);
|
|
670
|
+
return {
|
|
671
|
+
content: [{ type: "text", text: `${config.requestErrorPrefix}${errorText}` }],
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
if (!args.wait_for_result) {
|
|
675
|
+
return {
|
|
676
|
+
content: [{ type: "text", text: config.confirmationText(args.ticket_number) }],
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
const getUrl = buildGetUrl(config.getEndpoint(args.ticket_number), { repo_name: REPO_NAME });
|
|
680
|
+
const result = await pollForResult(getUrl, 900_000, config.pollLabel(args.ticket_number));
|
|
681
|
+
if (!result.ok) {
|
|
682
|
+
return { content: [{ type: "text", text: result.text }] };
|
|
683
|
+
}
|
|
684
|
+
// The combined GET endpoint returns JSON. Parse and fan out into markdown.
|
|
685
|
+
let envelope;
|
|
686
|
+
try {
|
|
687
|
+
envelope = JSON.parse(result.text);
|
|
688
|
+
}
|
|
689
|
+
catch (parseErr) {
|
|
690
|
+
return {
|
|
691
|
+
content: [{
|
|
692
|
+
type: "text",
|
|
693
|
+
text: `Failed to parse ticket review response: ${parseErr}\nRaw body: ${result.text}`,
|
|
694
|
+
}],
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
const clarify = envelope.clarify ?? {};
|
|
698
|
+
const critique = envelope.critique ?? {};
|
|
699
|
+
const parts = [];
|
|
700
|
+
let notes = "";
|
|
701
|
+
if (clarify.status === "success" && typeof clarify.content === "string" && clarify.content) {
|
|
702
|
+
parts.push(clarify.content);
|
|
703
|
+
if (args.save_locally && clarify.doc_type) {
|
|
704
|
+
const filename = `${args.ticket_number}-${clarify.doc_type}`;
|
|
705
|
+
const note = await saveLocally(await getTicketArtifactDocsPath(config.clarifySaveSubdir), filename, clarify.content);
|
|
706
|
+
notes += note;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
parts.push(`> **Note:** Clarifying questions sub-flow ${clarify.status ?? "unavailable"} (no content returned).`);
|
|
711
|
+
}
|
|
712
|
+
if (critique.status === "success" && typeof critique.content === "string" && critique.content) {
|
|
713
|
+
parts.push(critique.content);
|
|
714
|
+
if (args.save_locally && critique.doc_type) {
|
|
715
|
+
const filename = `${args.ticket_number}-${critique.doc_type}`;
|
|
716
|
+
const note = await saveLocally(await getTicketArtifactDocsPath(config.critiqueSaveSubdir), filename, critique.content);
|
|
717
|
+
notes += note;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
else {
|
|
721
|
+
parts.push(`> **Note:** Ticket critique sub-flow ${critique.status ?? "unavailable"} (no content returned).`);
|
|
722
|
+
}
|
|
723
|
+
const text = parts.join("\n\n---\n\n") + notes;
|
|
724
|
+
return { content: [{ type: "text", text }] };
|
|
725
|
+
}
|
|
222
726
|
// ---------------------------------------------------------------------------
|
|
223
727
|
// CLI: --init scaffolds slash commands and MCP configs into the current project
|
|
224
728
|
// ---------------------------------------------------------------------------
|
|
225
729
|
function buildBridgeApiEntry(cwd) {
|
|
730
|
+
// Secret-free by design: BAPI_API_KEY is NEVER scaffolded into generated MCP
|
|
731
|
+
// config. The server self-resolves the key from the environment or the
|
|
732
|
+
// home-dir credential store (~/.config/bridge/credentials.json) at runtime.
|
|
226
733
|
return {
|
|
227
734
|
command: "npx",
|
|
228
735
|
args: ["-y", "@bridge_gpt/mcp-server"],
|
|
229
736
|
env: {
|
|
230
737
|
BAPI_BASE_URL: "https://bridgegpt-api.com",
|
|
231
738
|
BAPI_REPO_NAME: "YOUR_REPO_NAME",
|
|
232
|
-
BAPI_API_KEY: "YOUR_API_KEY",
|
|
233
739
|
BAPI_DOCS_DIR: "docs/tmp",
|
|
234
740
|
BAPI_PROJECT_ROOT: cwd,
|
|
235
741
|
},
|
|
236
742
|
};
|
|
237
743
|
}
|
|
744
|
+
/**
|
|
745
|
+
* Choose the `repo_name` written into a scaffolded `.bridge/config`. Uses the
|
|
746
|
+
* cwd basename when it passes manifest repo-name validation; otherwise falls
|
|
747
|
+
* back to the `YOUR_REPO_NAME` placeholder for the user to edit.
|
|
748
|
+
*/
|
|
749
|
+
export function chooseScaffoldRepoName(cwd) {
|
|
750
|
+
const validated = validateRepoName(path.basename(cwd));
|
|
751
|
+
return validated.ok ? validated.value : "YOUR_REPO_NAME";
|
|
752
|
+
}
|
|
753
|
+
/** Build the secret-free `.bridge/config` TOML scaffolded by `--init`. */
|
|
754
|
+
export function buildBridgeConfigManifest(repoName) {
|
|
755
|
+
return [
|
|
756
|
+
"# Bridge API repository MCP manifest (committed, secret-free, machine-agnostic).",
|
|
757
|
+
"#",
|
|
758
|
+
"# Inherited by every git worktree; start-tickets worktree provisioning reads it",
|
|
759
|
+
"# to decide which Bridge API MCP registrations to write. Do not add credentials,",
|
|
760
|
+
"# connection URLs, or machine-specific paths here — those are resolved at runtime.",
|
|
761
|
+
"",
|
|
762
|
+
`repo_name = "${repoName}"`,
|
|
763
|
+
"",
|
|
764
|
+
"[[mcp]]",
|
|
765
|
+
'target = "bapi"',
|
|
766
|
+
"",
|
|
767
|
+
"# Optional: declare additional (Tier-2) MCP targets here. Each non-bapi target",
|
|
768
|
+
"# names the real command/args to launch and the credential-store bundle that",
|
|
769
|
+
"# supplies its secrets — never the secrets themselves. Uncomment and adapt:",
|
|
770
|
+
"#",
|
|
771
|
+
"# [[mcp]]",
|
|
772
|
+
'# target = "sfcc"',
|
|
773
|
+
'# command = "npx"',
|
|
774
|
+
'# args = ["-y", "@salesforce/b2c-dx-mcp"]',
|
|
775
|
+
'# secret_bundle = "sfcc:YOUR_SANDBOX_ID"',
|
|
776
|
+
"",
|
|
777
|
+
].join("\n");
|
|
778
|
+
}
|
|
238
779
|
async function ensureGitignored(cwd, filePath) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
catch { /* .gitignore doesn't exist yet */ }
|
|
246
|
-
// Check if already present (exact line match)
|
|
247
|
-
const lines = content.split("\n");
|
|
248
|
-
if (lines.some((line) => line.trim() === entry))
|
|
249
|
-
return;
|
|
250
|
-
const separator = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
|
|
251
|
-
await writeFile(gitignorePath, content + separator + entry + "\n", "utf-8");
|
|
780
|
+
await ensureGitignoredShared(cwd, filePath, {
|
|
781
|
+
readFile: (p) => readFile(p, "utf-8"),
|
|
782
|
+
writeFile: (p, data) => writeFile(p, data, "utf-8"),
|
|
783
|
+
mkdir: (p, options) => mkdir(p, options),
|
|
784
|
+
});
|
|
252
785
|
}
|
|
253
786
|
/**
|
|
254
787
|
* Core initialization logic shared by --init and --upgrade.
|
|
@@ -605,10 +1138,34 @@ body explicitly says to serialize structured output.
|
|
|
605
1138
|
console.log(` ${path.relative(cwd, examplePath)} (written)`);
|
|
606
1139
|
}
|
|
607
1140
|
console.log(` ${path.relative(cwd, instrDir)}/ (ensured)`);
|
|
1141
|
+
// ---- Phase 6b: Scaffold the committed secret-free `.bridge/config` ----
|
|
1142
|
+
// This manifest is committed and inherited by every worktree; it is the
|
|
1143
|
+
// input to start-tickets worktree MCP provisioning. It is intentionally
|
|
1144
|
+
// NOT gitignored (unlike the .mcp.json configs above). Credentials are
|
|
1145
|
+
// resolved at runtime, never written here.
|
|
1146
|
+
const bridgeConfigPath = path.join(cwd, ".bridge", "config");
|
|
1147
|
+
let bridgeConfigExists = false;
|
|
1148
|
+
try {
|
|
1149
|
+
await stat(bridgeConfigPath);
|
|
1150
|
+
bridgeConfigExists = true;
|
|
1151
|
+
}
|
|
1152
|
+
catch { }
|
|
1153
|
+
if (bridgeConfigExists) {
|
|
1154
|
+
console.log(`\n.bridge/config: skipped — already exists`);
|
|
1155
|
+
}
|
|
1156
|
+
else {
|
|
1157
|
+
await mkdir(path.dirname(bridgeConfigPath), { recursive: true });
|
|
1158
|
+
await writeFile(bridgeConfigPath, buildBridgeConfigManifest(chooseScaffoldRepoName(cwd)), "utf-8");
|
|
1159
|
+
console.log(`\n.bridge/config: written`);
|
|
1160
|
+
}
|
|
1161
|
+
console.log(" Credentials are resolved at runtime from BAPI_API_KEY or " +
|
|
1162
|
+
"~/.config/bridge/credentials.json (no secrets are written to .bridge/config).");
|
|
608
1163
|
// ---- Phase 7: Final summary ----
|
|
609
1164
|
if (anyCreatedOrAdded) {
|
|
610
|
-
console.log("\
|
|
611
|
-
"
|
|
1165
|
+
console.log("\nSet BAPI_REPO_NAME in your config files. Do NOT put BAPI_API_KEY in the " +
|
|
1166
|
+
"generated MCP config — supply it via the BAPI_API_KEY environment variable, " +
|
|
1167
|
+
"or store it under \"bapi:<repo_name>\" in ~/.config/bridge/credentials.json. " +
|
|
1168
|
+
"Get your values from the Bridge API setup UI at https://bridgegpt-api.com");
|
|
612
1169
|
}
|
|
613
1170
|
}
|
|
614
1171
|
// ---------------------------------------------------------------------------
|
|
@@ -699,11 +1256,31 @@ async function dispatchCliSubcommand(argv) {
|
|
|
699
1256
|
if (argv[0] === "start-tickets") {
|
|
700
1257
|
return runStartTicketsCli(argv.slice(1));
|
|
701
1258
|
}
|
|
1259
|
+
// The internal `mcp-invoke` worktree shim (BAPI-337) is a positional
|
|
1260
|
+
// subcommand routed before the flag guards and well before MCP server
|
|
1261
|
+
// construction: it resolves identity/credentials from `--project-root` and
|
|
1262
|
+
// then spawns the real server itself, so it must never fall through to the
|
|
1263
|
+
// normal no-subcommand startup path.
|
|
1264
|
+
if (argv[0] === "mcp-invoke") {
|
|
1265
|
+
return runMcpInvokeCli(argv.slice(1));
|
|
1266
|
+
}
|
|
702
1267
|
// The read-only `doctor` subcommand is routed beside start-tickets, before the
|
|
703
1268
|
// flag guards and well before MCP server construction (it never starts the server).
|
|
704
1269
|
if (argv[0] === "doctor") {
|
|
705
1270
|
return runDoctorCli(argv.slice(1));
|
|
706
1271
|
}
|
|
1272
|
+
// The local-only `schedule-run` subcommand (BAPI-327) is likewise a positional
|
|
1273
|
+
// subcommand that owns the rest of argv, routed before the --init / --upgrade
|
|
1274
|
+
// flag guards and never starts the MCP server.
|
|
1275
|
+
if (argv[0] === "schedule-run") {
|
|
1276
|
+
return runScheduleRunCli(argv.slice(1));
|
|
1277
|
+
}
|
|
1278
|
+
// The read-only `agent-capabilities` toolkit is likewise a positional subcommand,
|
|
1279
|
+
// routed before the flag guards and never starts the MCP server. It only spawns
|
|
1280
|
+
// disposable agent probes in temp dirs.
|
|
1281
|
+
if (argv[0] === "agent-capabilities") {
|
|
1282
|
+
return runAgentCapabilitiesCli(argv.slice(1));
|
|
1283
|
+
}
|
|
707
1284
|
// --init takes precedence over --upgrade; both are position-independent flags.
|
|
708
1285
|
if (argv.includes("--init")) {
|
|
709
1286
|
return runInitCli(cwd);
|
|
@@ -792,7 +1369,19 @@ const registerTool = ((name, config, handler) => {
|
|
|
792
1369
|
// ---------------------------------------------------------------------------
|
|
793
1370
|
// Tools
|
|
794
1371
|
// ---------------------------------------------------------------------------
|
|
1372
|
+
// SECURITY: The `annotations` objects below (readOnlyHint, destructiveHint,
|
|
1373
|
+
// idempotentHint, openWorldHint) are UNTRUSTED UX / model-routing hints only.
|
|
1374
|
+
// They are advisory metadata forwarded verbatim to the MCP client and MUST
|
|
1375
|
+
// NEVER be used for authorization, permission checks, access control, tool
|
|
1376
|
+
// enable/disable decisions, or any other security decision. Authorization is
|
|
1377
|
+
// enforced server-side via the API key + repo access checks, never here.
|
|
795
1378
|
registerTool("ping", {
|
|
1379
|
+
annotations: {
|
|
1380
|
+
readOnlyHint: true,
|
|
1381
|
+
destructiveHint: false,
|
|
1382
|
+
idempotentHint: true,
|
|
1383
|
+
openWorldHint: true,
|
|
1384
|
+
},
|
|
796
1385
|
description: "Test connectivity to Bridge API. Validates that the API key is accepted and the configured repository is accessible. " +
|
|
797
1386
|
"Returns JSON with {status: 'ok', repo_name: '<configured repo>'}. " +
|
|
798
1387
|
"Use this as a quick health check before other operations, or to verify your Bridge API configuration is working. " +
|
|
@@ -801,13 +1390,21 @@ registerTool("ping", {
|
|
|
801
1390
|
inputSchema: {},
|
|
802
1391
|
}, async () => {
|
|
803
1392
|
const url = buildGetUrl("/ping", { repo_name: REPO_NAME });
|
|
804
|
-
const resp = await fetch(url, { headers:
|
|
1393
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
805
1394
|
const text = await handleResponse(resp);
|
|
806
1395
|
return { content: [{ type: "text", text }] };
|
|
807
1396
|
});
|
|
808
1397
|
registerTool("second_opinion", {
|
|
809
|
-
|
|
1398
|
+
annotations: {
|
|
1399
|
+
readOnlyHint: true,
|
|
1400
|
+
destructiveHint: false,
|
|
1401
|
+
idempotentHint: true,
|
|
1402
|
+
openWorldHint: true,
|
|
1403
|
+
},
|
|
1404
|
+
description: "Get an IMMEDIATE, ad hoc independent critique or pushback on a plan, recommendation, or analysis you ALREADY HAVE in hand, from a different LLM model family. " +
|
|
810
1405
|
"Use this when you want a second, independent opinion before acting on a non-trivial decision — for example, when committing to an implementation approach, a risky refactor, an architectural trade-off, or a recommendation you would otherwise present to a user with no further validation. " +
|
|
1406
|
+
"This tool does NOT create or retrieve any Bridge artifact: it does not generate or fetch plans, ticket critiques, clarifying questions, architecture docs, deep research, brainstorms, or reimplementation context, and it stores nothing. It simply returns the other model's reply to your prompt right now. " +
|
|
1407
|
+
"If instead you want a Bridge ARTIFACT (a plan, critique, clarifying questions, architecture doc, reimplementation context, etc.) generated by a different provider, do NOT use this tool — call the matching `request_*` tool and set that tool's own `second_opinion` or `provider` parameter to route its generation to another provider. " +
|
|
811
1408
|
"Pick a provider from a DIFFERENT model family than the one you are running on so the response is genuinely independent (e.g. if you are an OpenAI agent, ask 'anthropic' or 'gemini'). " +
|
|
812
1409
|
"Pick a model tier appropriate for the depth of pushback you want: CHEAP_MODEL for a quick sanity check, BASIC_MODEL for a focused review, PREMIUM_MODEL for serious architectural pushback. " +
|
|
813
1410
|
"The 'prompt' you supply should be a complete, self-contained brief: the full plan, recommendation, or question you want challenged, along with whatever context is needed to evaluate it. The server adds a system prompt that frames the responder as an independent senior engineer and injects lightweight project context. " +
|
|
@@ -829,7 +1426,7 @@ registerTool("second_opinion", {
|
|
|
829
1426
|
}, async ({ prompt, provider, model }) => {
|
|
830
1427
|
const resp = await fetch(buildApiUrl("/llm/second-opinion"), {
|
|
831
1428
|
method: "POST",
|
|
832
|
-
headers:
|
|
1429
|
+
headers: await getPostHeaders(),
|
|
833
1430
|
body: JSON.stringify({
|
|
834
1431
|
repo_name: REPO_NAME,
|
|
835
1432
|
prompt,
|
|
@@ -840,7 +1437,104 @@ registerTool("second_opinion", {
|
|
|
840
1437
|
const text = await handleResponse(resp);
|
|
841
1438
|
return { content: [{ type: "text", text }] };
|
|
842
1439
|
});
|
|
1440
|
+
registerTool("generate_image", {
|
|
1441
|
+
annotations: {
|
|
1442
|
+
readOnlyHint: false,
|
|
1443
|
+
destructiveHint: false,
|
|
1444
|
+
idempotentHint: false,
|
|
1445
|
+
openWorldHint: true,
|
|
1446
|
+
},
|
|
1447
|
+
description: "Generate an image from a text prompt using a provider image model. " +
|
|
1448
|
+
"This tool spends provider credits on every call — cost scales with quality (low/medium/high). " +
|
|
1449
|
+
"Defaults to low quality to minimize provider spend; increase quality only when fidelity matters. " +
|
|
1450
|
+
"Returns native MCP image content (type: 'image') so the caller receives the image directly. " +
|
|
1451
|
+
"Google Imagen outputs (provider='gemini') include an invisible SynthID watermark applied server-side by Google.",
|
|
1452
|
+
inputSchema: {
|
|
1453
|
+
prompt: z
|
|
1454
|
+
.string()
|
|
1455
|
+
.min(1)
|
|
1456
|
+
.max(8000)
|
|
1457
|
+
.describe("Text prompt sent to the image provider."),
|
|
1458
|
+
provider: z
|
|
1459
|
+
.enum(["openai", "gemini"])
|
|
1460
|
+
.optional()
|
|
1461
|
+
.default("openai")
|
|
1462
|
+
.describe("Image provider. Defaults to 'openai' (gpt-image-2)."),
|
|
1463
|
+
quality: z
|
|
1464
|
+
.enum(["low", "medium", "high"])
|
|
1465
|
+
.optional()
|
|
1466
|
+
.default("low")
|
|
1467
|
+
.describe("Image quality. Defaults to 'low' for cost control."),
|
|
1468
|
+
size: z
|
|
1469
|
+
.enum(["1024x1024", "1024x1536", "1536x1024"])
|
|
1470
|
+
.optional()
|
|
1471
|
+
.default("1024x1024")
|
|
1472
|
+
.describe("Image dimensions. Defaults to '1024x1024'."),
|
|
1473
|
+
save_locally: z
|
|
1474
|
+
.boolean()
|
|
1475
|
+
.optional()
|
|
1476
|
+
.default(false)
|
|
1477
|
+
.describe("When true, save the generated image to the local docs/images directory."),
|
|
1478
|
+
},
|
|
1479
|
+
}, async ({ prompt, provider, quality, size, save_locally }) => {
|
|
1480
|
+
const resp = await fetch(buildApiUrl("/llm/generate-image"), {
|
|
1481
|
+
method: "POST",
|
|
1482
|
+
headers: await getPostHeaders(),
|
|
1483
|
+
body: JSON.stringify({
|
|
1484
|
+
repo_name: REPO_NAME,
|
|
1485
|
+
prompt,
|
|
1486
|
+
provider,
|
|
1487
|
+
quality,
|
|
1488
|
+
size,
|
|
1489
|
+
}),
|
|
1490
|
+
});
|
|
1491
|
+
if (!resp.ok) {
|
|
1492
|
+
const text = await handleResponse(resp);
|
|
1493
|
+
return { content: [{ type: "text", text }] };
|
|
1494
|
+
}
|
|
1495
|
+
const body = (await resp.json());
|
|
1496
|
+
const imageBase64 = body.image_base64;
|
|
1497
|
+
if (typeof imageBase64 !== "string" || imageBase64.length === 0) {
|
|
1498
|
+
return {
|
|
1499
|
+
content: [
|
|
1500
|
+
{
|
|
1501
|
+
type: "text",
|
|
1502
|
+
text: JSON.stringify({ error: "Image generation succeeded but response is missing image_base64", status: 500 }),
|
|
1503
|
+
},
|
|
1504
|
+
],
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
const mimeType = typeof body.mime_type === "string" && body.mime_type.length > 0
|
|
1508
|
+
? body.mime_type
|
|
1509
|
+
: "image/png";
|
|
1510
|
+
const content = [
|
|
1511
|
+
{ type: "image", data: imageBase64, mimeType },
|
|
1512
|
+
];
|
|
1513
|
+
if (save_locally) {
|
|
1514
|
+
try {
|
|
1515
|
+
const imagesDir = await getDocsPath("images");
|
|
1516
|
+
const filename = `generated-image-${safeTimestampForFilename()}.png`;
|
|
1517
|
+
const filePath = `${imagesDir}/${filename}`;
|
|
1518
|
+
await mkdir(imagesDir, { recursive: true });
|
|
1519
|
+
await writeFile(filePath, Buffer.from(imageBase64, "base64"));
|
|
1520
|
+
content.push({ type: "text", text: `Saved to ${filePath}` });
|
|
1521
|
+
}
|
|
1522
|
+
catch (saveErr) {
|
|
1523
|
+
content.push({
|
|
1524
|
+
type: "text",
|
|
1525
|
+
text: `Warning: image generated successfully but local save failed: ${saveErr instanceof Error ? saveErr.message : String(saveErr)}`,
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
return { content };
|
|
1530
|
+
});
|
|
843
1531
|
registerTool("get_project_standards", {
|
|
1532
|
+
annotations: {
|
|
1533
|
+
readOnlyHint: true,
|
|
1534
|
+
destructiveHint: false,
|
|
1535
|
+
idempotentHint: true,
|
|
1536
|
+
openWorldHint: true,
|
|
1537
|
+
},
|
|
844
1538
|
description: "Retrieve project-specific coding standards, architecture guidelines, testing standards, code review standards, and project context (platform, version, project description) for the configured repository. " +
|
|
845
1539
|
"Returns structured markdown with sections for project context, architecture instructions, code review correctness standards, testing stack information, and build analysis. " +
|
|
846
1540
|
"Only sections with configured values are included. Returns 404 if no standards are configured. " +
|
|
@@ -848,13 +1542,19 @@ registerTool("get_project_standards", {
|
|
|
848
1542
|
inputSchema: {},
|
|
849
1543
|
}, async () => {
|
|
850
1544
|
const url = buildGetUrl("/project-standards", { repo_name: REPO_NAME });
|
|
851
|
-
const resp = await fetch(url, { headers:
|
|
1545
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
852
1546
|
const text = await handleResponse(resp);
|
|
853
1547
|
return { content: [{ type: "text", text }] };
|
|
854
1548
|
});
|
|
855
1549
|
registerTool("get_tickets", {
|
|
1550
|
+
annotations: {
|
|
1551
|
+
readOnlyHint: true,
|
|
1552
|
+
destructiveHint: false,
|
|
1553
|
+
idempotentHint: true,
|
|
1554
|
+
openWorldHint: true,
|
|
1555
|
+
},
|
|
856
1556
|
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. " +
|
|
1557
|
+
"Filters by query text, status name, label, or date. Returns up to 'limit' tickets ordered by most recently updated. " +
|
|
858
1558
|
"All data is fetched live from Jira. Use get_ticket to retrieve full details for a specific ticket.",
|
|
859
1559
|
inputSchema: {
|
|
860
1560
|
query: z
|
|
@@ -867,6 +1567,12 @@ registerTool("get_tickets", {
|
|
|
867
1567
|
.string()
|
|
868
1568
|
.optional()
|
|
869
1569
|
.describe("Filter by Jira status name (e.g. 'To Do', 'In Progress', 'Done')"),
|
|
1570
|
+
labels: z
|
|
1571
|
+
.string()
|
|
1572
|
+
.optional()
|
|
1573
|
+
.describe("Comma-separated Jira labels. Filters tickets via JQL labels in (...) " +
|
|
1574
|
+
"(matches tickets carrying any of the given labels). Labels cannot contain spaces. " +
|
|
1575
|
+
"Example: \"bapi-idea-to-ticket-fa-1a2b3c\""),
|
|
870
1576
|
limit: z
|
|
871
1577
|
.number()
|
|
872
1578
|
.optional()
|
|
@@ -882,12 +1588,14 @@ registerTool("get_tickets", {
|
|
|
882
1588
|
.optional()
|
|
883
1589
|
.describe("ISO date string (YYYY-MM-DD). Only return tickets updated on or after this date"),
|
|
884
1590
|
},
|
|
885
|
-
}, async ({ query, status, limit, offset, updated_since }) => {
|
|
1591
|
+
}, async ({ query, status, labels, limit, offset, updated_since }) => {
|
|
886
1592
|
const params = { repo_name: REPO_NAME };
|
|
887
1593
|
if (query)
|
|
888
1594
|
params.query = query;
|
|
889
1595
|
if (status)
|
|
890
1596
|
params.status = status;
|
|
1597
|
+
if (labels)
|
|
1598
|
+
params.labels = labels;
|
|
891
1599
|
if (limit !== undefined)
|
|
892
1600
|
params.limit = String(limit);
|
|
893
1601
|
if (offset !== undefined && offset > 0)
|
|
@@ -895,11 +1603,29 @@ registerTool("get_tickets", {
|
|
|
895
1603
|
if (updated_since)
|
|
896
1604
|
params.updated_since = updated_since;
|
|
897
1605
|
const url = buildGetUrl("/tickets", params);
|
|
898
|
-
const resp = await fetch(url, { headers:
|
|
899
|
-
const
|
|
1606
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
1607
|
+
const ok = resp.ok;
|
|
1608
|
+
let text = await handleResponse(resp);
|
|
1609
|
+
if (ok) {
|
|
1610
|
+
const filename = buildTicketsSearchFilename({
|
|
1611
|
+
query,
|
|
1612
|
+
status,
|
|
1613
|
+
labels,
|
|
1614
|
+
updated_since,
|
|
1615
|
+
limit,
|
|
1616
|
+
offset,
|
|
1617
|
+
});
|
|
1618
|
+
text = await truncateAndSaveIfNeeded(text, await getDocsPath("tickets-search"), filename);
|
|
1619
|
+
}
|
|
900
1620
|
return { content: [{ type: "text", text }] };
|
|
901
1621
|
});
|
|
902
1622
|
registerTool("get_ticket", {
|
|
1623
|
+
annotations: {
|
|
1624
|
+
readOnlyHint: true,
|
|
1625
|
+
destructiveHint: false,
|
|
1626
|
+
idempotentHint: true,
|
|
1627
|
+
openWorldHint: true,
|
|
1628
|
+
},
|
|
903
1629
|
description: "Retrieve full details for a single Jira ticket by its key. " +
|
|
904
1630
|
"Returns summary, status, type, assignee, reporter, description, and timestamps. " +
|
|
905
1631
|
"All data is fetched live from Jira. Use get_tickets to search/list multiple tickets. " +
|
|
@@ -911,11 +1637,23 @@ registerTool("get_ticket", {
|
|
|
911
1637
|
},
|
|
912
1638
|
}, async ({ ticket_number }) => {
|
|
913
1639
|
const url = buildGetUrl(`/tickets/${encodeURIComponent(ticket_number)}`, { repo_name: REPO_NAME });
|
|
914
|
-
const resp = await fetch(url, { headers:
|
|
915
|
-
const
|
|
1640
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
1641
|
+
const ok = resp.ok;
|
|
1642
|
+
let text = await handleResponse(resp);
|
|
1643
|
+
if (ok) {
|
|
1644
|
+
const safeTicket = safeTicketFileSegment(ticket_number);
|
|
1645
|
+
const filename = `${safeTicket}.json`;
|
|
1646
|
+
text = await truncateAndSaveIfNeeded(text, await getDocsPath("tickets"), filename);
|
|
1647
|
+
}
|
|
916
1648
|
return { content: [{ type: "text", text }] };
|
|
917
1649
|
});
|
|
918
1650
|
registerTool("get_comments", {
|
|
1651
|
+
annotations: {
|
|
1652
|
+
readOnlyHint: true,
|
|
1653
|
+
destructiveHint: false,
|
|
1654
|
+
idempotentHint: true,
|
|
1655
|
+
openWorldHint: true,
|
|
1656
|
+
},
|
|
919
1657
|
description: "Retrieve all comments on a Jira ticket, oldest-first. " +
|
|
920
1658
|
"Returns an array of {id, author, body, created, updated}. " +
|
|
921
1659
|
"Comment bodies are Markdown (converted from Jira wiki markup). " +
|
|
@@ -928,15 +1666,28 @@ registerTool("get_comments", {
|
|
|
928
1666
|
},
|
|
929
1667
|
}, async ({ ticket_number }) => {
|
|
930
1668
|
const url = buildGetUrl(`/tickets/${encodeURIComponent(ticket_number)}/comments`, { repo_name: REPO_NAME });
|
|
931
|
-
const resp = await fetch(url, { headers:
|
|
932
|
-
const
|
|
1669
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
1670
|
+
const ok = resp.ok;
|
|
1671
|
+
let text = await handleResponse(resp);
|
|
1672
|
+
if (ok) {
|
|
1673
|
+
const safeTicket = safeTicketFileSegment(ticket_number);
|
|
1674
|
+
const filename = `${safeTicket}-comments.json`;
|
|
1675
|
+
text = await truncateAndSaveIfNeeded(text, await getDocsPath("comments"), filename);
|
|
1676
|
+
}
|
|
933
1677
|
return { content: [{ type: "text", text }] };
|
|
934
1678
|
});
|
|
935
1679
|
registerTool("create_ticket", {
|
|
1680
|
+
annotations: {
|
|
1681
|
+
readOnlyHint: false,
|
|
1682
|
+
destructiveHint: false,
|
|
1683
|
+
idempotentHint: false,
|
|
1684
|
+
openWorldHint: true,
|
|
1685
|
+
},
|
|
936
1686
|
description: "Create a new Jira ticket in the configured project. Requires either description or file_path (or both — file_path takes precedence). " +
|
|
937
1687
|
"Returns JSON with {ticket_key: 'PROJ-123', url: 'https://...'}. " +
|
|
938
1688
|
"The ticket is created immediately in Jira — confirm details with the user before calling. " +
|
|
939
|
-
"The description field supports Jira markdown formatting."
|
|
1689
|
+
"The description field supports Jira markdown formatting. " +
|
|
1690
|
+
"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
1691
|
inputSchema: {
|
|
941
1692
|
summary: z.string().describe("Ticket title — keep under 100 characters"),
|
|
942
1693
|
description: z
|
|
@@ -966,19 +1717,31 @@ registerTool("create_ticket", {
|
|
|
966
1717
|
.string()
|
|
967
1718
|
.optional()
|
|
968
1719
|
.describe("Jira username or account ID of the assignee. Omit to leave unassigned"),
|
|
1720
|
+
parent_key: z
|
|
1721
|
+
.string()
|
|
1722
|
+
.optional()
|
|
1723
|
+
.describe("Optional Jira Epic key to set as the parent of the newly created child issue. " +
|
|
1724
|
+
"Omit for standalone tickets and Epic parent creation."),
|
|
969
1725
|
},
|
|
970
|
-
}, async ({ summary, description, file_path, issue_type, priority, labels, assignee }) => {
|
|
1726
|
+
}, async ({ summary, description, file_path, issue_type, priority, labels, assignee, parent_key }) => {
|
|
971
1727
|
const resolved = await resolveTextOrFile(description, file_path, "description");
|
|
972
1728
|
if (!resolved.ok)
|
|
973
1729
|
return resolved.errorResponse;
|
|
974
|
-
const text = await createTicketRequest({ summary, description: resolved.text, issue_type, priority, labels, assignee });
|
|
1730
|
+
const text = await createTicketRequest({ summary, description: resolved.text, issue_type, priority, labels, assignee, parent_key });
|
|
975
1731
|
return { content: [{ type: "text", text: text + resolved.note }] };
|
|
976
1732
|
});
|
|
977
1733
|
registerTool("get_plan", {
|
|
978
|
-
|
|
1734
|
+
annotations: {
|
|
1735
|
+
readOnlyHint: true,
|
|
1736
|
+
destructiveHint: false,
|
|
1737
|
+
idempotentHint: true,
|
|
1738
|
+
openWorldHint: true,
|
|
1739
|
+
},
|
|
1740
|
+
description: "RETRIEVE an already-generated implementation plan for a Jira ticket. This tool only fetches an existing plan — it does NOT start or trigger plan generation. " +
|
|
1741
|
+
"If no plan exists yet (or you need a fresh one), call `request_plan_generation` first; it starts the async generation and this `get_plan` tool retrieves the result. " +
|
|
979
1742
|
"Returns the full plan as markdown text — present it verbatim without summarizing. " +
|
|
980
1743
|
"The plan includes step-by-step implementation guidance with code file references. " +
|
|
981
|
-
"Returns 404
|
|
1744
|
+
"Returns a 404 / not-found response when no plan is ready yet (the ticket may not have been processed by Bridge API) — that means generation has not run, not that this tool failed. " +
|
|
982
1745
|
"Tip: call get_clarifying_questions for the same ticket to get the full context for implementation.",
|
|
983
1746
|
inputSchema: {
|
|
984
1747
|
ticket_number: z
|
|
@@ -991,23 +1754,21 @@ registerTool("get_plan", {
|
|
|
991
1754
|
.describe("Whether to save the plan to a local file in the BAPI_DOCS_DIR/plans/ directory. " +
|
|
992
1755
|
"Defaults to true. Set to false to skip saving."),
|
|
993
1756
|
},
|
|
994
|
-
}, async (
|
|
995
|
-
|
|
996
|
-
const resp = await fetch(url, { headers: GET_HEADERS });
|
|
997
|
-
const ok = resp.ok;
|
|
998
|
-
let text = await handleResponse(resp);
|
|
999
|
-
if (ok && save_locally) {
|
|
1000
|
-
const note = await saveLocally(getDocsPath("plans"), `${ticket_number}-plan.md`, text);
|
|
1001
|
-
text += note;
|
|
1002
|
-
}
|
|
1003
|
-
return { content: [{ type: "text", text }] };
|
|
1757
|
+
}, async (args) => {
|
|
1758
|
+
return getTicketArtifact("plan", args);
|
|
1004
1759
|
});
|
|
1005
1760
|
registerTool("get_architecture", {
|
|
1006
|
-
|
|
1761
|
+
annotations: {
|
|
1762
|
+
readOnlyHint: true,
|
|
1763
|
+
destructiveHint: false,
|
|
1764
|
+
idempotentHint: true,
|
|
1765
|
+
openWorldHint: true,
|
|
1766
|
+
},
|
|
1767
|
+
description: "RETRIEVE an already-generated architecture plan for a Jira ticket. This tool only fetches an existing architecture plan — it does NOT start or trigger generation. " +
|
|
1768
|
+
"If no architecture plan exists yet (or you need a fresh one), call `request_architecture` first; it starts the async generation and this `get_architecture` tool retrieves the result. " +
|
|
1007
1769
|
"Returns the full architecture plan as markdown text — present it verbatim without summarizing. " +
|
|
1008
1770
|
"The plan includes high-level architectural decisions, component design, and integration guidance. " +
|
|
1009
|
-
"Returns 404
|
|
1010
|
-
"Tip: use request_architecture to trigger architecture plan generation first.",
|
|
1771
|
+
"Returns a 404 / not-found response when no architecture plan is ready yet — that means generation has not run, not that this tool failed.",
|
|
1011
1772
|
inputSchema: {
|
|
1012
1773
|
ticket_number: z
|
|
1013
1774
|
.string()
|
|
@@ -1019,21 +1780,20 @@ registerTool("get_architecture", {
|
|
|
1019
1780
|
.describe("Whether to save the architecture plan to a local file in the BAPI_DOCS_DIR/architecture/ directory. " +
|
|
1020
1781
|
"Defaults to true. Set to false to skip saving."),
|
|
1021
1782
|
},
|
|
1022
|
-
}, async (
|
|
1023
|
-
|
|
1024
|
-
const resp = await fetch(url, { headers: GET_HEADERS });
|
|
1025
|
-
const ok = resp.ok;
|
|
1026
|
-
let text = await handleResponse(resp);
|
|
1027
|
-
if (ok && save_locally) {
|
|
1028
|
-
const note = await saveLocally(getDocsPath("architecture"), `${ticket_number}-architecture-plan.md`, text);
|
|
1029
|
-
text += note;
|
|
1030
|
-
}
|
|
1031
|
-
return { content: [{ type: "text", text }] };
|
|
1783
|
+
}, async (args) => {
|
|
1784
|
+
return getTicketArtifact("architecture", args);
|
|
1032
1785
|
});
|
|
1033
1786
|
registerTool("get_clarifying_questions", {
|
|
1034
|
-
|
|
1787
|
+
annotations: {
|
|
1788
|
+
readOnlyHint: true,
|
|
1789
|
+
destructiveHint: false,
|
|
1790
|
+
idempotentHint: true,
|
|
1791
|
+
openWorldHint: true,
|
|
1792
|
+
},
|
|
1793
|
+
description: "RETRIEVE already-generated clarifying questions (for feature/task tickets) or debugging guidance (for bug tickets) for a Jira ticket. This tool only fetches existing questions — it does NOT start or trigger generation. " +
|
|
1794
|
+
"If no questions exist yet (or you need fresh ones), call `request_clarifying_questions` first; it starts the async generation and this `get_clarifying_questions` tool retrieves the result. " +
|
|
1035
1795
|
"Returns markdown text with questions that should be resolved before implementation begins. " +
|
|
1036
|
-
"Returns 404
|
|
1796
|
+
"Returns a 404 / not-found response when no questions are ready yet — that means generation has not run, not that this tool failed. " +
|
|
1037
1797
|
"Tip: call get_plan for the same ticket to get the implementation plan alongside these questions.",
|
|
1038
1798
|
inputSchema: {
|
|
1039
1799
|
ticket_number: z
|
|
@@ -1046,18 +1806,16 @@ registerTool("get_clarifying_questions", {
|
|
|
1046
1806
|
.describe("Whether to save the clarifying questions to a local file in the BAPI_DOCS_DIR/clarifying-questions/ directory. " +
|
|
1047
1807
|
"Defaults to true. Set to false to skip saving."),
|
|
1048
1808
|
},
|
|
1049
|
-
}, async (
|
|
1050
|
-
|
|
1051
|
-
const resp = await fetch(url, { headers: GET_HEADERS });
|
|
1052
|
-
const ok = resp.ok;
|
|
1053
|
-
let text = await handleResponse(resp);
|
|
1054
|
-
if (ok && save_locally) {
|
|
1055
|
-
const note = await saveLocally(getDocsPath("clarifying-questions"), `${ticket_number}-clarifying-questions.md`, text);
|
|
1056
|
-
text += note;
|
|
1057
|
-
}
|
|
1058
|
-
return { content: [{ type: "text", text }] };
|
|
1809
|
+
}, async (args) => {
|
|
1810
|
+
return getTicketArtifact("clarifying_questions", args);
|
|
1059
1811
|
});
|
|
1060
1812
|
registerTool("parse_repository", {
|
|
1813
|
+
annotations: {
|
|
1814
|
+
readOnlyHint: false,
|
|
1815
|
+
destructiveHint: false,
|
|
1816
|
+
idempotentHint: true,
|
|
1817
|
+
openWorldHint: true,
|
|
1818
|
+
},
|
|
1061
1819
|
description: "Queue a background job to parse and index the repository for Bridge API's AI agents. " +
|
|
1062
1820
|
"This should be run after major codebase changes so that plans and questions reflect the latest code. " +
|
|
1063
1821
|
"Returns 202 with {message: 'Repository parsing queued'} on success, " +
|
|
@@ -1077,13 +1835,19 @@ registerTool("parse_repository", {
|
|
|
1077
1835
|
payload.directory_path = directory_path;
|
|
1078
1836
|
const resp = await fetch(buildUrl("/parse-repository"), {
|
|
1079
1837
|
method: "POST",
|
|
1080
|
-
headers:
|
|
1838
|
+
headers: await getPostHeaders(),
|
|
1081
1839
|
body: JSON.stringify(payload),
|
|
1082
1840
|
});
|
|
1083
1841
|
const text = await handleResponse(resp);
|
|
1084
1842
|
return { content: [{ type: "text", text }] };
|
|
1085
1843
|
});
|
|
1086
1844
|
registerTool("regenerate_directory_map", {
|
|
1845
|
+
annotations: {
|
|
1846
|
+
readOnlyHint: false,
|
|
1847
|
+
destructiveHint: false,
|
|
1848
|
+
idempotentHint: true,
|
|
1849
|
+
openWorldHint: true,
|
|
1850
|
+
},
|
|
1087
1851
|
description: "Regenerate the repository directory map and return the result. " +
|
|
1088
1852
|
"Unlike parse_repository (which is async), this tool is synchronous — it blocks until " +
|
|
1089
1853
|
"the directory map is generated and returns the full map text directly. " +
|
|
@@ -1096,7 +1860,7 @@ registerTool("regenerate_directory_map", {
|
|
|
1096
1860
|
try {
|
|
1097
1861
|
const resp = await fetch(buildUrl("/regenerate-directory-map"), {
|
|
1098
1862
|
method: "POST",
|
|
1099
|
-
headers:
|
|
1863
|
+
headers: await getPostHeaders(),
|
|
1100
1864
|
body: JSON.stringify({ repo_name: REPO_NAME }),
|
|
1101
1865
|
signal: controller.signal,
|
|
1102
1866
|
});
|
|
@@ -1108,6 +1872,12 @@ registerTool("regenerate_directory_map", {
|
|
|
1108
1872
|
}
|
|
1109
1873
|
});
|
|
1110
1874
|
registerTool("get_parse_status", {
|
|
1875
|
+
annotations: {
|
|
1876
|
+
readOnlyHint: true,
|
|
1877
|
+
destructiveHint: false,
|
|
1878
|
+
idempotentHint: true,
|
|
1879
|
+
openWorldHint: true,
|
|
1880
|
+
},
|
|
1111
1881
|
description: "Check whether a repository parse job is currently running. " +
|
|
1112
1882
|
"Returns {status: 'in_progress', started_at: '<ISO timestamp>'} if a parse is active, " +
|
|
1113
1883
|
"or {status: 'idle'} if no parse is running. " +
|
|
@@ -1116,11 +1886,17 @@ registerTool("get_parse_status", {
|
|
|
1116
1886
|
inputSchema: {},
|
|
1117
1887
|
}, async () => {
|
|
1118
1888
|
const url = buildGetUrl("/parse-status", { repo_name: REPO_NAME });
|
|
1119
|
-
const resp = await fetch(url, { headers:
|
|
1889
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
1120
1890
|
const text = await handleResponse(resp);
|
|
1121
1891
|
return { content: [{ type: "text", text }] };
|
|
1122
1892
|
});
|
|
1123
1893
|
registerTool("add_comment", {
|
|
1894
|
+
annotations: {
|
|
1895
|
+
readOnlyHint: false,
|
|
1896
|
+
destructiveHint: false,
|
|
1897
|
+
idempotentHint: false,
|
|
1898
|
+
openWorldHint: true,
|
|
1899
|
+
},
|
|
1124
1900
|
description: "Post a comment on a Jira ticket. The comment appears immediately in Jira. " +
|
|
1125
1901
|
"Supports markdown formatting. " +
|
|
1126
1902
|
"For long comments (over ~2000 characters), set attach_as_file to true — " +
|
|
@@ -1168,13 +1944,19 @@ registerTool("add_comment", {
|
|
|
1168
1944
|
}
|
|
1169
1945
|
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/comment`), {
|
|
1170
1946
|
method: "POST",
|
|
1171
|
-
headers:
|
|
1947
|
+
headers: await getPostHeaders(),
|
|
1172
1948
|
body: JSON.stringify(payload),
|
|
1173
1949
|
});
|
|
1174
1950
|
const text = await handleResponse(resp);
|
|
1175
1951
|
return { content: [{ type: "text", text: text + resolved.note }] };
|
|
1176
1952
|
});
|
|
1177
1953
|
registerTool("update_ticket_description", {
|
|
1954
|
+
annotations: {
|
|
1955
|
+
readOnlyHint: false,
|
|
1956
|
+
destructiveHint: true,
|
|
1957
|
+
idempotentHint: true,
|
|
1958
|
+
openWorldHint: true,
|
|
1959
|
+
},
|
|
1178
1960
|
description: "Update the description of an existing Jira ticket. This is a direct, synchronous update that overwrites the existing description with the provided text. " +
|
|
1179
1961
|
"The description should be in markdown format — it will be automatically converted to Jira wiki markup. " +
|
|
1180
1962
|
"This does NOT create a new ticket. Use create_ticket for that. " +
|
|
@@ -1200,13 +1982,19 @@ registerTool("update_ticket_description", {
|
|
|
1200
1982
|
return resolved.errorResponse;
|
|
1201
1983
|
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/description`), {
|
|
1202
1984
|
method: "PUT",
|
|
1203
|
-
headers:
|
|
1985
|
+
headers: await getPostHeaders(),
|
|
1204
1986
|
body: JSON.stringify({ repo_name: REPO_NAME, description: resolved.text }),
|
|
1205
1987
|
});
|
|
1206
1988
|
const text = await handleResponse(resp);
|
|
1207
1989
|
return { content: [{ type: "text", text: text + resolved.note }] };
|
|
1208
1990
|
});
|
|
1209
1991
|
registerTool("upload_attachment", {
|
|
1992
|
+
annotations: {
|
|
1993
|
+
readOnlyHint: false,
|
|
1994
|
+
destructiveHint: false,
|
|
1995
|
+
idempotentHint: false,
|
|
1996
|
+
openWorldHint: true,
|
|
1997
|
+
},
|
|
1210
1998
|
description: "Upload a local file as an attachment to a Jira ticket. " +
|
|
1211
1999
|
"Supports text/UTF-8 files only (markdown, plain text, etc.). " +
|
|
1212
2000
|
"Optionally syncs the content to Bridge API's tickets_links table so retrieval endpoints " +
|
|
@@ -1263,13 +2051,19 @@ registerTool("upload_attachment", {
|
|
|
1263
2051
|
}
|
|
1264
2052
|
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/attachment`), {
|
|
1265
2053
|
method: "POST",
|
|
1266
|
-
headers:
|
|
2054
|
+
headers: await getPostHeaders(),
|
|
1267
2055
|
body: JSON.stringify(payload),
|
|
1268
2056
|
});
|
|
1269
2057
|
const text = await handleResponse(resp);
|
|
1270
2058
|
return { content: [{ type: "text", text: text + resolved.note }] };
|
|
1271
2059
|
});
|
|
1272
2060
|
registerTool("list_attachments", {
|
|
2061
|
+
annotations: {
|
|
2062
|
+
readOnlyHint: true,
|
|
2063
|
+
destructiveHint: false,
|
|
2064
|
+
idempotentHint: true,
|
|
2065
|
+
openWorldHint: true,
|
|
2066
|
+
},
|
|
1273
2067
|
description: "List attachments on a Jira ticket. " +
|
|
1274
2068
|
"Returns metadata (ID, filename, MIME type, size, created date) for each attachment. " +
|
|
1275
2069
|
"By default, AI-generated attachments are excluded. " +
|
|
@@ -1289,12 +2083,23 @@ registerTool("list_attachments", {
|
|
|
1289
2083
|
params.include_ai_generated = "true";
|
|
1290
2084
|
}
|
|
1291
2085
|
const url = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/attachments`, params);
|
|
1292
|
-
const resp = await fetch(url, { headers:
|
|
1293
|
-
const
|
|
2086
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
2087
|
+
const ok = resp.ok;
|
|
2088
|
+
let text = await handleResponse(resp);
|
|
2089
|
+
if (ok) {
|
|
2090
|
+
const safeTicket = safeTicketFileSegment(ticket_number);
|
|
2091
|
+
const filename = `${safeTicket}-attachment-list.json`;
|
|
2092
|
+
text = await truncateAndSaveIfNeeded(text, await getDocsPath("attachments"), filename);
|
|
2093
|
+
}
|
|
1294
2094
|
return { content: [{ type: "text", text }] };
|
|
1295
2095
|
});
|
|
1296
|
-
const MAX_INLINE_TEXT_LENGTH = 50_000;
|
|
1297
2096
|
registerTool("download_attachment", {
|
|
2097
|
+
annotations: {
|
|
2098
|
+
readOnlyHint: true,
|
|
2099
|
+
destructiveHint: false,
|
|
2100
|
+
idempotentHint: true,
|
|
2101
|
+
openWorldHint: true,
|
|
2102
|
+
},
|
|
1298
2103
|
description: "Download an attachment from a Jira ticket and save it to disk. " +
|
|
1299
2104
|
"Specify either attachment_id or filename (not both). " +
|
|
1300
2105
|
"For text files, the content is returned inline (truncated at ~50KB) and also saved to disk. " +
|
|
@@ -1335,7 +2140,7 @@ registerTool("download_attachment", {
|
|
|
1335
2140
|
if (filename)
|
|
1336
2141
|
params.filename = filename;
|
|
1337
2142
|
const url = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/attachments/download`, params);
|
|
1338
|
-
const resp = await fetch(url, { headers:
|
|
2143
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
1339
2144
|
if (!resp.ok) {
|
|
1340
2145
|
const text = await handleResponse(resp);
|
|
1341
2146
|
return { content: [{ type: "text", text }] };
|
|
@@ -1350,9 +2155,9 @@ registerTool("download_attachment", {
|
|
|
1350
2155
|
const safeTicket = path.basename(ticket_number);
|
|
1351
2156
|
const savePath = file_path
|
|
1352
2157
|
? file_path
|
|
1353
|
-
: path.join(
|
|
2158
|
+
: path.join(await getDocsDir(), "attachments", safeTicket, safeFileName);
|
|
1354
2159
|
const resolvedSave = path.resolve(savePath);
|
|
1355
|
-
const resolvedRoot = path.resolve(
|
|
2160
|
+
const resolvedRoot = path.resolve(await getProjectRoot());
|
|
1356
2161
|
if (!resolvedSave.startsWith(resolvedRoot + path.sep) && resolvedSave !== resolvedRoot) {
|
|
1357
2162
|
return {
|
|
1358
2163
|
content: [{
|
|
@@ -1383,10 +2188,16 @@ registerTool("download_attachment", {
|
|
|
1383
2188
|
return { content: [{ type: "text", text: resultText }] };
|
|
1384
2189
|
});
|
|
1385
2190
|
registerTool("request_plan_generation", {
|
|
1386
|
-
|
|
2191
|
+
annotations: {
|
|
2192
|
+
readOnlyHint: false,
|
|
2193
|
+
destructiveHint: false,
|
|
2194
|
+
idempotentHint: false,
|
|
2195
|
+
openWorldHint: true,
|
|
2196
|
+
},
|
|
2197
|
+
description: "START (or refresh) async generation of an implementation plan for a Jira ticket. " +
|
|
1387
2198
|
"This triggers an asynchronous background job — results are NOT immediate. " +
|
|
1388
2199
|
"Processing typically takes 1-5 minutes depending on ticket complexity and number of attachments. " +
|
|
1389
|
-
"
|
|
2200
|
+
"The matching get_plan tool retrieves the generated plan later (call get_plan with the same ticket_number) — unless you set wait_for_result, in which case this call blocks and returns the plan directly. " +
|
|
1390
2201
|
"Returns 202 if the request was accepted, 404 if the ticket does not exist in Jira, " +
|
|
1391
2202
|
"or 403 if the API key is unauthorized. " +
|
|
1392
2203
|
"Set wait_for_result to true to block until the result is ready (typically 1-5 minutes) instead of returning immediately.",
|
|
@@ -1407,55 +2218,34 @@ registerTool("request_plan_generation", {
|
|
|
1407
2218
|
.default(true)
|
|
1408
2219
|
.describe("When wait_for_result is true, whether to save the plan to a local file in the " +
|
|
1409
2220
|
"BAPI_DOCS_DIR/plans/ directory. Defaults to true. Ignored when wait_for_result is false."),
|
|
1410
|
-
second_opinion: z
|
|
2221
|
+
second_opinion: z
|
|
2222
|
+
.string()
|
|
2223
|
+
.optional()
|
|
2224
|
+
.describe("Provider routing override for THIS artifact-generation request " +
|
|
2225
|
+
"(e.g. 'anthropic', 'openai', 'gemini'). When set, the artifact is " +
|
|
2226
|
+
"generated by the named provider and, where supported, a cross-provider " +
|
|
2227
|
+
"second-opinion pass is applied to this request only. " +
|
|
2228
|
+
"This is NOT the standalone `second_opinion` tool — it does not return " +
|
|
2229
|
+
"an ad hoc critique; it only changes which provider produces this " +
|
|
2230
|
+
"request's artifact. Takes precedence over `provider` when both are set."),
|
|
1411
2231
|
provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
|
|
1412
2232
|
"triggering second-opinion semantics. If both provider and second_opinion are set, " +
|
|
1413
2233
|
"second_opinion takes precedence."),
|
|
1414
2234
|
},
|
|
1415
|
-
}, async (
|
|
1416
|
-
|
|
1417
|
-
const trimmedSecondOpinion = second_opinion?.trim();
|
|
1418
|
-
if (trimmedSecondOpinion) {
|
|
1419
|
-
body.provider_override = trimmedSecondOpinion;
|
|
1420
|
-
}
|
|
1421
|
-
const trimmedProvider = provider?.trim();
|
|
1422
|
-
if (trimmedProvider && !trimmedSecondOpinion) {
|
|
1423
|
-
body.provider = trimmedProvider;
|
|
1424
|
-
}
|
|
1425
|
-
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/generate-plan`), {
|
|
1426
|
-
method: "POST",
|
|
1427
|
-
headers: POST_HEADERS,
|
|
1428
|
-
body: JSON.stringify(body),
|
|
1429
|
-
});
|
|
1430
|
-
if (!resp.ok) {
|
|
1431
|
-
const errorText = await handleResponse(resp);
|
|
1432
|
-
return {
|
|
1433
|
-
content: [{ type: "text", text: `Failed to request plan generation: ${errorText}` }],
|
|
1434
|
-
};
|
|
1435
|
-
}
|
|
1436
|
-
if (wait_for_result) {
|
|
1437
|
-
const getUrl = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/plan`, { repo_name: REPO_NAME });
|
|
1438
|
-
const result = await pollForResult(getUrl, 900_000, `Plan generation for ${ticket_number}`);
|
|
1439
|
-
if (!result.ok) {
|
|
1440
|
-
return { content: [{ type: "text", text: result.text }] };
|
|
1441
|
-
}
|
|
1442
|
-
let text = result.text;
|
|
1443
|
-
if (save_locally) {
|
|
1444
|
-
const note = await saveLocally(getDocsPath("plans"), `${ticket_number}-plan.md`, text);
|
|
1445
|
-
text += note;
|
|
1446
|
-
}
|
|
1447
|
-
return { content: [{ type: "text", text }] };
|
|
1448
|
-
}
|
|
1449
|
-
const confirmationText = `Plan generation requested for ${ticket_number}. ` +
|
|
1450
|
-
`Processing typically takes 1-5 minutes. ` +
|
|
1451
|
-
`Use get_plan with ticket_number "${ticket_number}" to retrieve the plan once processing completes.`;
|
|
1452
|
-
return { content: [{ type: "text", text: confirmationText }] };
|
|
2235
|
+
}, async (args) => {
|
|
2236
|
+
return requestTicketArtifact("plan", args);
|
|
1453
2237
|
});
|
|
1454
2238
|
registerTool("request_architecture", {
|
|
1455
|
-
|
|
2239
|
+
annotations: {
|
|
2240
|
+
readOnlyHint: false,
|
|
2241
|
+
destructiveHint: false,
|
|
2242
|
+
idempotentHint: false,
|
|
2243
|
+
openWorldHint: true,
|
|
2244
|
+
},
|
|
2245
|
+
description: "START (or refresh) async generation of an architecture plan for a Jira ticket. " +
|
|
1456
2246
|
"This triggers an asynchronous background job — results are NOT immediate. " +
|
|
1457
2247
|
"Processing typically takes 2-4 minutes depending on ticket complexity. " +
|
|
1458
|
-
"
|
|
2248
|
+
"The matching get_architecture tool retrieves the generated architecture plan later (call get_architecture with the same ticket_number) — unless you set wait_for_result, in which case this call blocks and returns it directly. " +
|
|
1459
2249
|
"Returns 202 if the request was accepted, 404 if the ticket does not exist in Jira, " +
|
|
1460
2250
|
"or 403 if the API key is unauthorized. " +
|
|
1461
2251
|
"Set wait_for_result to true to block until the result is ready (typically 2-4 minutes) instead of returning immediately.",
|
|
@@ -1476,55 +2266,34 @@ registerTool("request_architecture", {
|
|
|
1476
2266
|
.default(true)
|
|
1477
2267
|
.describe("When wait_for_result is true, whether to save the architecture plan to a local file in the " +
|
|
1478
2268
|
"BAPI_DOCS_DIR/architecture/ directory. Defaults to true. Only takes effect when wait_for_result is true."),
|
|
1479
|
-
second_opinion: z
|
|
2269
|
+
second_opinion: z
|
|
2270
|
+
.string()
|
|
2271
|
+
.optional()
|
|
2272
|
+
.describe("Provider routing override for THIS artifact-generation request " +
|
|
2273
|
+
"(e.g. 'anthropic', 'openai', 'gemini'). When set, the artifact is " +
|
|
2274
|
+
"generated by the named provider and, where supported, a cross-provider " +
|
|
2275
|
+
"second-opinion pass is applied to this request only. " +
|
|
2276
|
+
"This is NOT the standalone `second_opinion` tool — it does not return " +
|
|
2277
|
+
"an ad hoc critique; it only changes which provider produces this " +
|
|
2278
|
+
"request's artifact. Takes precedence over `provider` when both are set."),
|
|
1480
2279
|
provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
|
|
1481
2280
|
"triggering second-opinion semantics. If both provider and second_opinion are set, " +
|
|
1482
2281
|
"second_opinion takes precedence."),
|
|
1483
2282
|
},
|
|
1484
|
-
}, async (
|
|
1485
|
-
|
|
1486
|
-
const trimmedSecondOpinion = second_opinion?.trim();
|
|
1487
|
-
if (trimmedSecondOpinion) {
|
|
1488
|
-
body.provider_override = trimmedSecondOpinion;
|
|
1489
|
-
}
|
|
1490
|
-
const trimmedProvider = provider?.trim();
|
|
1491
|
-
if (trimmedProvider && !trimmedSecondOpinion) {
|
|
1492
|
-
body.provider = trimmedProvider;
|
|
1493
|
-
}
|
|
1494
|
-
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/generate-architecture`), {
|
|
1495
|
-
method: "POST",
|
|
1496
|
-
headers: POST_HEADERS,
|
|
1497
|
-
body: JSON.stringify(body),
|
|
1498
|
-
});
|
|
1499
|
-
if (!resp.ok) {
|
|
1500
|
-
const errorText = await handleResponse(resp);
|
|
1501
|
-
return {
|
|
1502
|
-
content: [{ type: "text", text: `Failed to request architecture generation: ${errorText}` }],
|
|
1503
|
-
};
|
|
1504
|
-
}
|
|
1505
|
-
if (wait_for_result) {
|
|
1506
|
-
const getUrl = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/architecture-plan`, { repo_name: REPO_NAME });
|
|
1507
|
-
const result = await pollForResult(getUrl, 900_000, `Architecture generation for ${ticket_number}`);
|
|
1508
|
-
if (!result.ok) {
|
|
1509
|
-
return { content: [{ type: "text", text: result.text }] };
|
|
1510
|
-
}
|
|
1511
|
-
let text = result.text;
|
|
1512
|
-
if (save_locally) {
|
|
1513
|
-
const note = await saveLocally(getDocsPath("architecture"), `${ticket_number}-architecture-plan.md`, text);
|
|
1514
|
-
text += note;
|
|
1515
|
-
}
|
|
1516
|
-
return { content: [{ type: "text", text }] };
|
|
1517
|
-
}
|
|
1518
|
-
const confirmationText = `Architecture generation requested for ${ticket_number}. ` +
|
|
1519
|
-
`Processing typically takes 2-4 minutes. ` +
|
|
1520
|
-
`Use get_architecture with ticket_number "${ticket_number}" to retrieve the architecture plan once processing completes.`;
|
|
1521
|
-
return { content: [{ type: "text", text: confirmationText }] };
|
|
2283
|
+
}, async (args) => {
|
|
2284
|
+
return requestTicketArtifact("architecture", args);
|
|
1522
2285
|
});
|
|
1523
2286
|
registerTool("request_clarifying_questions", {
|
|
1524
|
-
|
|
2287
|
+
annotations: {
|
|
2288
|
+
readOnlyHint: false,
|
|
2289
|
+
destructiveHint: false,
|
|
2290
|
+
idempotentHint: false,
|
|
2291
|
+
openWorldHint: true,
|
|
2292
|
+
},
|
|
2293
|
+
description: "START (or refresh) async generation of clarifying questions or debugging guidance for a Jira ticket. " +
|
|
1525
2294
|
"This triggers an asynchronous background job — results are NOT immediate. " +
|
|
1526
2295
|
"Processing typically takes 1-5 minutes. " +
|
|
1527
|
-
"
|
|
2296
|
+
"The matching get_clarifying_questions tool retrieves the generated questions later (call get_clarifying_questions with the same ticket_number) — unless you set wait_for_result, in which case this call blocks and returns them directly. " +
|
|
1528
2297
|
"For bug tickets, the result may be debugging guidance instead of clarifying questions. " +
|
|
1529
2298
|
"Returns 202 if the request was accepted, 404 if the ticket does not exist in Jira, " +
|
|
1530
2299
|
"or 403 if the API key is unauthorized. " +
|
|
@@ -1546,59 +2315,38 @@ registerTool("request_clarifying_questions", {
|
|
|
1546
2315
|
.default(true)
|
|
1547
2316
|
.describe("When wait_for_result is true, whether to save the clarifying questions to a local file in the " +
|
|
1548
2317
|
"BAPI_DOCS_DIR/clarifying-questions/ directory. Defaults to true. Ignored when wait_for_result is false."),
|
|
1549
|
-
second_opinion: z
|
|
2318
|
+
second_opinion: z
|
|
2319
|
+
.string()
|
|
2320
|
+
.optional()
|
|
2321
|
+
.describe("Provider routing override for THIS artifact-generation request " +
|
|
2322
|
+
"(e.g. 'anthropic', 'openai', 'gemini'). When set, the artifact is " +
|
|
2323
|
+
"generated by the named provider and, where supported, a cross-provider " +
|
|
2324
|
+
"second-opinion pass is applied to this request only. " +
|
|
2325
|
+
"This is NOT the standalone `second_opinion` tool — it does not return " +
|
|
2326
|
+
"an ad hoc critique; it only changes which provider produces this " +
|
|
2327
|
+
"request's artifact. Takes precedence over `provider` when both are set."),
|
|
1550
2328
|
provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
|
|
1551
2329
|
"triggering second-opinion semantics. If both provider and second_opinion are set, " +
|
|
1552
2330
|
"second_opinion takes precedence."),
|
|
1553
2331
|
},
|
|
1554
|
-
}, async (
|
|
1555
|
-
|
|
1556
|
-
const trimmedSecondOpinion = second_opinion?.trim();
|
|
1557
|
-
if (trimmedSecondOpinion) {
|
|
1558
|
-
body.provider_override = trimmedSecondOpinion;
|
|
1559
|
-
}
|
|
1560
|
-
const trimmedProvider = provider?.trim();
|
|
1561
|
-
if (trimmedProvider && !trimmedSecondOpinion) {
|
|
1562
|
-
body.provider = trimmedProvider;
|
|
1563
|
-
}
|
|
1564
|
-
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/generate-clarifying-questions`), {
|
|
1565
|
-
method: "POST",
|
|
1566
|
-
headers: POST_HEADERS,
|
|
1567
|
-
body: JSON.stringify(body),
|
|
1568
|
-
});
|
|
1569
|
-
if (!resp.ok) {
|
|
1570
|
-
const errorText = await handleResponse(resp);
|
|
1571
|
-
return {
|
|
1572
|
-
content: [{ type: "text", text: `Failed to request clarifying questions: ${errorText}` }],
|
|
1573
|
-
};
|
|
1574
|
-
}
|
|
1575
|
-
if (wait_for_result) {
|
|
1576
|
-
const getUrl = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/clarifying-questions`, { repo_name: REPO_NAME });
|
|
1577
|
-
const result = await pollForResult(getUrl, 900_000, `Clarifying questions for ${ticket_number}`);
|
|
1578
|
-
if (!result.ok) {
|
|
1579
|
-
return { content: [{ type: "text", text: result.text }] };
|
|
1580
|
-
}
|
|
1581
|
-
let text = result.text;
|
|
1582
|
-
if (save_locally) {
|
|
1583
|
-
const note = await saveLocally(getDocsPath("clarifying-questions"), `${ticket_number}-clarifying-questions.md`, text);
|
|
1584
|
-
text += note;
|
|
1585
|
-
}
|
|
1586
|
-
return { content: [{ type: "text", text }] };
|
|
1587
|
-
}
|
|
1588
|
-
const confirmationText = `Clarifying questions requested for ${ticket_number}. ` +
|
|
1589
|
-
`Processing typically takes 1-5 minutes. ` +
|
|
1590
|
-
`Use get_clarifying_questions with ticket_number "${ticket_number}" to retrieve the results once processing completes.`;
|
|
1591
|
-
return { content: [{ type: "text", text: confirmationText }] };
|
|
2332
|
+
}, async (args) => {
|
|
2333
|
+
return requestTicketArtifact("clarifying_questions", args);
|
|
1592
2334
|
});
|
|
1593
2335
|
// ---------------------------------------------------------------------------
|
|
1594
2336
|
// Ticket Quality Critique
|
|
1595
2337
|
// ---------------------------------------------------------------------------
|
|
1596
2338
|
registerTool("get_ticket_critique", {
|
|
1597
|
-
|
|
2339
|
+
annotations: {
|
|
2340
|
+
readOnlyHint: true,
|
|
2341
|
+
destructiveHint: false,
|
|
2342
|
+
idempotentHint: true,
|
|
2343
|
+
openWorldHint: true,
|
|
2344
|
+
},
|
|
2345
|
+
description: "RETRIEVE an already-generated ticket quality critique for a Jira ticket. This tool only fetches an existing critique — it does NOT start or trigger generation. " +
|
|
2346
|
+
"If no critique exists yet (or you need a fresh one), call `request_ticket_critique` first; it starts the async generation and this `get_ticket_critique` tool retrieves the result. " +
|
|
1598
2347
|
"Returns markdown text with a structured critique covering Standards Conformance Analysis, " +
|
|
1599
2348
|
"Standards Deviations, and Suggested Improvements. " +
|
|
1600
|
-
"Returns 404
|
|
1601
|
-
"Tip: use request_ticket_critique to trigger critique generation first.",
|
|
2349
|
+
"Returns a 404 / not-found response when no critique is ready yet — that means generation has not run, not that this tool failed.",
|
|
1602
2350
|
inputSchema: {
|
|
1603
2351
|
ticket_number: z
|
|
1604
2352
|
.string()
|
|
@@ -1610,22 +2358,20 @@ registerTool("get_ticket_critique", {
|
|
|
1610
2358
|
.describe("Whether to save the critique to a local file in the BAPI_DOCS_DIR/ticket-critiques/ directory. " +
|
|
1611
2359
|
"Defaults to true. Set to false to skip saving."),
|
|
1612
2360
|
},
|
|
1613
|
-
}, async (
|
|
1614
|
-
|
|
1615
|
-
const resp = await fetch(url, { headers: GET_HEADERS });
|
|
1616
|
-
const ok = resp.ok;
|
|
1617
|
-
let text = await handleResponse(resp);
|
|
1618
|
-
if (ok && save_locally) {
|
|
1619
|
-
const note = await saveLocally(getDocsPath("ticket-critiques"), `${ticket_number}-ticket-quality-critique.md`, text);
|
|
1620
|
-
text += note;
|
|
1621
|
-
}
|
|
1622
|
-
return { content: [{ type: "text", text }] };
|
|
2361
|
+
}, async (args) => {
|
|
2362
|
+
return getTicketArtifact("ticket_critique", args);
|
|
1623
2363
|
});
|
|
1624
2364
|
registerTool("request_ticket_critique", {
|
|
1625
|
-
|
|
2365
|
+
annotations: {
|
|
2366
|
+
readOnlyHint: false,
|
|
2367
|
+
destructiveHint: false,
|
|
2368
|
+
idempotentHint: false,
|
|
2369
|
+
openWorldHint: true,
|
|
2370
|
+
},
|
|
2371
|
+
description: "START (or refresh) async generation of a ticket critique for a Jira ticket. " +
|
|
1626
2372
|
"This triggers an asynchronous background job — results are NOT immediate. " +
|
|
1627
2373
|
"Processing typically takes 1-5 minutes. " +
|
|
1628
|
-
"
|
|
2374
|
+
"The matching get_ticket_critique tool retrieves the generated critique later (call get_ticket_critique with the same ticket_number) — unless you set wait_for_result, in which case this call blocks and returns it directly. " +
|
|
1629
2375
|
"Returns 202 if the request was accepted, 404 if the ticket does not exist in Jira, " +
|
|
1630
2376
|
"or 403 if the API key is unauthorized. " +
|
|
1631
2377
|
"Set wait_for_result to true to block until the result is ready (typically 1-5 minutes) instead of returning immediately.",
|
|
@@ -1646,54 +2392,33 @@ registerTool("request_ticket_critique", {
|
|
|
1646
2392
|
.default(true)
|
|
1647
2393
|
.describe("When wait_for_result is true, whether to save the ticket critique to a local file in the " +
|
|
1648
2394
|
"BAPI_DOCS_DIR/ticket-critiques/ directory. Defaults to true. Ignored when wait_for_result is false."),
|
|
1649
|
-
second_opinion: z
|
|
2395
|
+
second_opinion: z
|
|
2396
|
+
.string()
|
|
2397
|
+
.optional()
|
|
2398
|
+
.describe("Provider routing override for THIS artifact-generation request " +
|
|
2399
|
+
"(e.g. 'anthropic', 'openai', 'gemini'). When set, the artifact is " +
|
|
2400
|
+
"generated by the named provider and, where supported, a cross-provider " +
|
|
2401
|
+
"second-opinion pass is applied to this request only. " +
|
|
2402
|
+
"This is NOT the standalone `second_opinion` tool — it does not return " +
|
|
2403
|
+
"an ad hoc critique; it only changes which provider produces this " +
|
|
2404
|
+
"request's artifact. Takes precedence over `provider` when both are set."),
|
|
1650
2405
|
provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
|
|
1651
2406
|
"triggering second-opinion semantics. If both provider and second_opinion are set, " +
|
|
1652
2407
|
"second_opinion takes precedence."),
|
|
1653
2408
|
},
|
|
1654
|
-
}, async (
|
|
1655
|
-
|
|
1656
|
-
const trimmedSecondOpinion = second_opinion?.trim();
|
|
1657
|
-
if (trimmedSecondOpinion) {
|
|
1658
|
-
body.provider_override = trimmedSecondOpinion;
|
|
1659
|
-
}
|
|
1660
|
-
const trimmedProvider = provider?.trim();
|
|
1661
|
-
if (trimmedProvider && !trimmedSecondOpinion) {
|
|
1662
|
-
body.provider = trimmedProvider;
|
|
1663
|
-
}
|
|
1664
|
-
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/generate-ticket-critique`), {
|
|
1665
|
-
method: "POST",
|
|
1666
|
-
headers: POST_HEADERS,
|
|
1667
|
-
body: JSON.stringify(body),
|
|
1668
|
-
});
|
|
1669
|
-
if (!resp.ok) {
|
|
1670
|
-
const errorText = await handleResponse(resp);
|
|
1671
|
-
return {
|
|
1672
|
-
content: [{ type: "text", text: `Failed to request ticket critique: ${errorText}` }],
|
|
1673
|
-
};
|
|
1674
|
-
}
|
|
1675
|
-
if (wait_for_result) {
|
|
1676
|
-
const getUrl = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/ticket-critique`, { repo_name: REPO_NAME });
|
|
1677
|
-
const result = await pollForResult(getUrl, 900_000, `Ticket critique for ${ticket_number}`);
|
|
1678
|
-
if (!result.ok) {
|
|
1679
|
-
return { content: [{ type: "text", text: result.text }] };
|
|
1680
|
-
}
|
|
1681
|
-
let text = result.text;
|
|
1682
|
-
if (save_locally) {
|
|
1683
|
-
const note = await saveLocally(getDocsPath("ticket-critiques"), `${ticket_number}-ticket-quality-critique.md`, text);
|
|
1684
|
-
text += note;
|
|
1685
|
-
}
|
|
1686
|
-
return { content: [{ type: "text", text }] };
|
|
1687
|
-
}
|
|
1688
|
-
const confirmationText = `Ticket critique requested for ${ticket_number}. ` +
|
|
1689
|
-
`Processing typically takes 1-5 minutes. ` +
|
|
1690
|
-
`Use get_ticket_critique with ticket_number "${ticket_number}" to retrieve the results once processing completes.`;
|
|
1691
|
-
return { content: [{ type: "text", text: confirmationText }] };
|
|
2409
|
+
}, async (args) => {
|
|
2410
|
+
return requestTicketArtifact("ticket_critique", args);
|
|
1692
2411
|
});
|
|
1693
2412
|
// ---------------------------------------------------------------------------
|
|
1694
2413
|
// Combined Ticket Review (clarify + critique)
|
|
1695
2414
|
// ---------------------------------------------------------------------------
|
|
1696
2415
|
registerTool("request_ticket_review", {
|
|
2416
|
+
annotations: {
|
|
2417
|
+
readOnlyHint: false,
|
|
2418
|
+
destructiveHint: false,
|
|
2419
|
+
idempotentHint: false,
|
|
2420
|
+
openWorldHint: true,
|
|
2421
|
+
},
|
|
1697
2422
|
description: "Request a combined ticket review that generates BOTH clarifying questions (or debugging guidance for bug tickets) " +
|
|
1698
2423
|
"AND a ticket quality critique in parallel on the server, halving wall-clock latency vs. running the two " +
|
|
1699
2424
|
"requests sequentially. This triggers an asynchronous background job — results are NOT immediate. " +
|
|
@@ -1718,93 +2443,37 @@ registerTool("request_ticket_review", {
|
|
|
1718
2443
|
.default(true)
|
|
1719
2444
|
.describe("When wait_for_result is true, whether to save each generated document to its canonical local path under " +
|
|
1720
2445
|
"BAPI_DOCS_DIR (clarifying-questions/ or ticket-critiques/). Defaults to true. Ignored when wait_for_result is false."),
|
|
1721
|
-
second_opinion: z
|
|
2446
|
+
second_opinion: z
|
|
2447
|
+
.string()
|
|
2448
|
+
.optional()
|
|
2449
|
+
.describe("Provider routing override for THIS artifact-generation request " +
|
|
2450
|
+
"(e.g. 'anthropic', 'openai', 'gemini'). When set, the artifact is " +
|
|
2451
|
+
"generated by the named provider and, where supported, a cross-provider " +
|
|
2452
|
+
"second-opinion pass is applied to this request only. " +
|
|
2453
|
+
"This is NOT the standalone `second_opinion` tool — it does not return " +
|
|
2454
|
+
"an ad hoc critique; it only changes which provider produces this " +
|
|
2455
|
+
"request's artifact. Takes precedence over `provider` when both are set."),
|
|
1722
2456
|
provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
|
|
1723
2457
|
"triggering second-opinion semantics. If both provider and second_opinion are set, " +
|
|
1724
2458
|
"second_opinion takes precedence."),
|
|
1725
2459
|
},
|
|
1726
|
-
}, async (
|
|
1727
|
-
|
|
1728
|
-
const trimmedSecondOpinion = second_opinion?.trim();
|
|
1729
|
-
if (trimmedSecondOpinion) {
|
|
1730
|
-
body.provider_override = trimmedSecondOpinion;
|
|
1731
|
-
}
|
|
1732
|
-
const trimmedProvider = provider?.trim();
|
|
1733
|
-
if (trimmedProvider && !trimmedSecondOpinion) {
|
|
1734
|
-
body.provider = trimmedProvider;
|
|
1735
|
-
}
|
|
1736
|
-
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/generate-ticket-review`), {
|
|
1737
|
-
method: "POST",
|
|
1738
|
-
headers: POST_HEADERS,
|
|
1739
|
-
body: JSON.stringify(body),
|
|
1740
|
-
});
|
|
1741
|
-
if (!resp.ok) {
|
|
1742
|
-
const errorText = await handleResponse(resp);
|
|
1743
|
-
return {
|
|
1744
|
-
content: [{ type: "text", text: `Failed to request ticket review: ${errorText}` }],
|
|
1745
|
-
};
|
|
1746
|
-
}
|
|
1747
|
-
if (wait_for_result) {
|
|
1748
|
-
const getUrl = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/ticket-review`, { repo_name: REPO_NAME });
|
|
1749
|
-
const result = await pollForResult(getUrl, 900_000, `Ticket review for ${ticket_number}`);
|
|
1750
|
-
if (!result.ok) {
|
|
1751
|
-
return { content: [{ type: "text", text: result.text }] };
|
|
1752
|
-
}
|
|
1753
|
-
// The combined GET endpoint returns JSON. Parse and fan out into markdown.
|
|
1754
|
-
let envelope;
|
|
1755
|
-
try {
|
|
1756
|
-
envelope = JSON.parse(result.text);
|
|
1757
|
-
}
|
|
1758
|
-
catch (parseErr) {
|
|
1759
|
-
return {
|
|
1760
|
-
content: [{
|
|
1761
|
-
type: "text",
|
|
1762
|
-
text: `Failed to parse ticket review response: ${parseErr}\nRaw body: ${result.text}`,
|
|
1763
|
-
}],
|
|
1764
|
-
};
|
|
1765
|
-
}
|
|
1766
|
-
const clarify = envelope.clarify ?? {};
|
|
1767
|
-
const critique = envelope.critique ?? {};
|
|
1768
|
-
const parts = [];
|
|
1769
|
-
let notes = "";
|
|
1770
|
-
if (clarify.status === "success" && typeof clarify.content === "string" && clarify.content) {
|
|
1771
|
-
parts.push(clarify.content);
|
|
1772
|
-
if (save_locally && clarify.doc_type) {
|
|
1773
|
-
const filename = `${ticket_number}-${clarify.doc_type}`;
|
|
1774
|
-
const note = await saveLocally(getDocsPath("clarifying-questions"), filename, clarify.content);
|
|
1775
|
-
notes += note;
|
|
1776
|
-
}
|
|
1777
|
-
}
|
|
1778
|
-
else {
|
|
1779
|
-
parts.push(`> **Note:** Clarifying questions sub-flow ${clarify.status ?? "unavailable"} (no content returned).`);
|
|
1780
|
-
}
|
|
1781
|
-
if (critique.status === "success" && typeof critique.content === "string" && critique.content) {
|
|
1782
|
-
parts.push(critique.content);
|
|
1783
|
-
if (save_locally && critique.doc_type) {
|
|
1784
|
-
const filename = `${ticket_number}-${critique.doc_type}`;
|
|
1785
|
-
const note = await saveLocally(getDocsPath("ticket-critiques"), filename, critique.content);
|
|
1786
|
-
notes += note;
|
|
1787
|
-
}
|
|
1788
|
-
}
|
|
1789
|
-
else {
|
|
1790
|
-
parts.push(`> **Note:** Ticket critique sub-flow ${critique.status ?? "unavailable"} (no content returned).`);
|
|
1791
|
-
}
|
|
1792
|
-
const text = parts.join("\n\n---\n\n") + notes;
|
|
1793
|
-
return { content: [{ type: "text", text }] };
|
|
1794
|
-
}
|
|
1795
|
-
const confirmationText = `Combined ticket review requested for ${ticket_number}. ` +
|
|
1796
|
-
`Processing typically takes 2-6 minutes. ` +
|
|
1797
|
-
`Use get_clarifying_questions and get_ticket_critique with ticket_number "${ticket_number}" to retrieve the two documents once processing completes.`;
|
|
1798
|
-
return { content: [{ type: "text", text: confirmationText }] };
|
|
2460
|
+
}, async (args) => {
|
|
2461
|
+
return requestTicketReview(args);
|
|
1799
2462
|
});
|
|
1800
2463
|
// ---------------------------------------------------------------------------
|
|
1801
2464
|
// Reimplement Context
|
|
1802
2465
|
// ---------------------------------------------------------------------------
|
|
1803
2466
|
registerTool("request_reimplement_context", {
|
|
1804
|
-
|
|
2467
|
+
annotations: {
|
|
2468
|
+
readOnlyHint: false,
|
|
2469
|
+
destructiveHint: false,
|
|
2470
|
+
idempotentHint: false,
|
|
2471
|
+
openWorldHint: true,
|
|
2472
|
+
},
|
|
2473
|
+
description: "START async processing of new attachments and context assembly for a previously-implemented Jira ticket. " +
|
|
1805
2474
|
"Use this for follow-up requests on tickets that have already been through the plan+implement cycle. " +
|
|
1806
2475
|
"This triggers an asynchronous background job to process new attachments/images. " +
|
|
1807
|
-
"
|
|
2476
|
+
"The matching get_reimplement_context tool retrieves the assembled context later (call get_reimplement_context with the same ticket_number) — unless you set wait_for_result, in which case this call blocks and returns it directly. " +
|
|
1808
2477
|
"Set wait_for_result to true to block until the context is ready instead of returning immediately.",
|
|
1809
2478
|
inputSchema: {
|
|
1810
2479
|
ticket_number: z
|
|
@@ -1822,56 +2491,35 @@ registerTool("request_reimplement_context", {
|
|
|
1822
2491
|
.default(true)
|
|
1823
2492
|
.describe("When wait_for_result is true, save the context markdown to " +
|
|
1824
2493
|
"BAPI_DOCS_DIR/reimplementations/{ticket_number}-context.md. Defaults to true."),
|
|
1825
|
-
second_opinion: z
|
|
2494
|
+
second_opinion: z
|
|
2495
|
+
.string()
|
|
2496
|
+
.optional()
|
|
2497
|
+
.describe("Provider routing override for THIS artifact-generation request " +
|
|
2498
|
+
"(e.g. 'anthropic', 'openai', 'gemini'). When set, the artifact is " +
|
|
2499
|
+
"generated by the named provider and, where supported, a cross-provider " +
|
|
2500
|
+
"second-opinion pass is applied to this request only. " +
|
|
2501
|
+
"This is NOT the standalone `second_opinion` tool — it does not return " +
|
|
2502
|
+
"an ad hoc critique; it only changes which provider produces this " +
|
|
2503
|
+
"request's artifact. Takes precedence over `provider` when both are set."),
|
|
1826
2504
|
provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
|
|
1827
2505
|
"triggering second-opinion semantics. If both provider and second_opinion are set, " +
|
|
1828
2506
|
"second_opinion takes precedence."),
|
|
1829
2507
|
},
|
|
1830
|
-
}, async (
|
|
1831
|
-
|
|
1832
|
-
const trimmedSecondOpinion = second_opinion?.trim();
|
|
1833
|
-
if (trimmedSecondOpinion) {
|
|
1834
|
-
body.provider_override = trimmedSecondOpinion;
|
|
1835
|
-
}
|
|
1836
|
-
const trimmedProvider = provider?.trim();
|
|
1837
|
-
if (trimmedProvider && !trimmedSecondOpinion) {
|
|
1838
|
-
body.provider = trimmedProvider;
|
|
1839
|
-
}
|
|
1840
|
-
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/request-reimplement-context`), {
|
|
1841
|
-
method: "POST",
|
|
1842
|
-
headers: POST_HEADERS,
|
|
1843
|
-
body: JSON.stringify(body),
|
|
1844
|
-
});
|
|
1845
|
-
if (!resp.ok) {
|
|
1846
|
-
const errorText = await handleResponse(resp);
|
|
1847
|
-
return {
|
|
1848
|
-
content: [{ type: "text", text: `Failed to request reimplement context: ${errorText}` }],
|
|
1849
|
-
};
|
|
1850
|
-
}
|
|
1851
|
-
if (wait_for_result) {
|
|
1852
|
-
const getUrl = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/reimplement-context`, { repo_name: REPO_NAME });
|
|
1853
|
-
const result = await pollForResult(getUrl, 900_000, `Reimplement context for ${ticket_number}`);
|
|
1854
|
-
if (!result.ok) {
|
|
1855
|
-
return { content: [{ type: "text", text: result.text }] };
|
|
1856
|
-
}
|
|
1857
|
-
let text = result.text;
|
|
1858
|
-
if (save_locally) {
|
|
1859
|
-
const note = await saveLocally(getDocsPath("reimplementations"), `${ticket_number}-context.md`, text);
|
|
1860
|
-
text += note;
|
|
1861
|
-
}
|
|
1862
|
-
return { content: [{ type: "text", text }] };
|
|
1863
|
-
}
|
|
1864
|
-
const confirmationText = `Reimplement context processing requested for ${ticket_number}. ` +
|
|
1865
|
-
`Processing typically takes 1-2 minutes. ` +
|
|
1866
|
-
`Use get_reimplement_context with ticket_number "${ticket_number}" to retrieve the results once processing completes.`;
|
|
1867
|
-
return { content: [{ type: "text", text: confirmationText }] };
|
|
2508
|
+
}, async (args) => {
|
|
2509
|
+
return requestTicketArtifact("reimplement_context", args);
|
|
1868
2510
|
});
|
|
1869
2511
|
registerTool("get_reimplement_context", {
|
|
1870
|
-
|
|
2512
|
+
annotations: {
|
|
2513
|
+
readOnlyHint: true,
|
|
2514
|
+
destructiveHint: false,
|
|
2515
|
+
idempotentHint: true,
|
|
2516
|
+
openWorldHint: true,
|
|
2517
|
+
},
|
|
2518
|
+
description: "RETRIEVE an already-assembled reimplement context for a Jira ticket. This tool only fetches an existing result — it does NOT start or trigger processing. " +
|
|
2519
|
+
"If the reimplement context does not exist yet (or you need a fresh one), call `request_reimplement_context` first; it starts the async processing and this `get_reimplement_context` tool retrieves the result. " +
|
|
1871
2520
|
"Returns a markdown document with new/changed information diffed against stored state, " +
|
|
1872
2521
|
"the original ticket description, and the existing implementation plan. " +
|
|
1873
|
-
"Returns 404
|
|
1874
|
-
"Tip: use request_reimplement_context to trigger processing first.",
|
|
2522
|
+
"Returns a 404 / not-found response when processing is not yet complete — that means processing has not finished, not that this tool failed.",
|
|
1875
2523
|
inputSchema: {
|
|
1876
2524
|
ticket_number: z
|
|
1877
2525
|
.string()
|
|
@@ -1882,35 +2530,24 @@ registerTool("get_reimplement_context", {
|
|
|
1882
2530
|
.default(true)
|
|
1883
2531
|
.describe("Save the context to BAPI_DOCS_DIR/reimplementations/{ticket_number}-context.md. Defaults to true."),
|
|
1884
2532
|
},
|
|
1885
|
-
}, async (
|
|
1886
|
-
|
|
1887
|
-
if (resp.status === 404) {
|
|
1888
|
-
return {
|
|
1889
|
-
content: [{
|
|
1890
|
-
type: "text",
|
|
1891
|
-
text: JSON.stringify({
|
|
1892
|
-
error: "NOT_FOUND",
|
|
1893
|
-
message: `Reimplement context for ${ticket_number} is not yet available. ` +
|
|
1894
|
-
`Processing may still be in progress. Try again in a moment, ` +
|
|
1895
|
-
`or call request_reimplement_context to trigger processing.`,
|
|
1896
|
-
}),
|
|
1897
|
-
}],
|
|
1898
|
-
};
|
|
1899
|
-
}
|
|
1900
|
-
const text = await handleResponse(resp);
|
|
1901
|
-
if (save_locally) {
|
|
1902
|
-
const note = await saveLocally(getDocsPath("reimplementations"), `${ticket_number}-context.md`, text);
|
|
1903
|
-
return { content: [{ type: "text", text: text + note }] };
|
|
1904
|
-
}
|
|
1905
|
-
return { content: [{ type: "text", text }] };
|
|
2533
|
+
}, async (args) => {
|
|
2534
|
+
return getTicketArtifact("reimplement_context", args);
|
|
1906
2535
|
});
|
|
1907
2536
|
// ---------------------------------------------------------------------------
|
|
1908
2537
|
// Ticket Lifecycle Tracking
|
|
1909
2538
|
// ---------------------------------------------------------------------------
|
|
1910
2539
|
registerTool("track_ticket", {
|
|
1911
|
-
|
|
2540
|
+
annotations: {
|
|
2541
|
+
readOnlyHint: false,
|
|
2542
|
+
destructiveHint: false,
|
|
2543
|
+
idempotentHint: true,
|
|
2544
|
+
openWorldHint: true,
|
|
2545
|
+
},
|
|
2546
|
+
description: "Write/update Bridge API's DATABASE lifecycle-tracking record for a ticket ONLY. This registers the ticket in Bridge's own database so workflow state timestamps (critique, clarify, plan, implement) can be tracked. " +
|
|
2547
|
+
"It does NOT edit anything in Jira: it does not change the Jira summary, description, comments, attachments, or status. " +
|
|
1912
2548
|
"If the ticket is already tracked, this is a safe no-op — it upserts the description and repo_name without error. " +
|
|
1913
|
-
"
|
|
2549
|
+
"After create_ticket, this is the correct next step when you want Bridge to track that ticket's workflow timestamps / artifact state. " +
|
|
2550
|
+
"For Jira mutations use a different tool instead: `update_ticket_description` to replace the Jira description, `add_comment` to post a Jira comment, and `update_jira_status` to move the Jira workflow status. " +
|
|
1914
2551
|
"The repo_name is automatically injected from the configured environment.",
|
|
1915
2552
|
inputSchema: {
|
|
1916
2553
|
ticket_number: z.string().describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123)"),
|
|
@@ -1922,13 +2559,19 @@ registerTool("track_ticket", {
|
|
|
1922
2559
|
payload.description = description;
|
|
1923
2560
|
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/track`), {
|
|
1924
2561
|
method: "POST",
|
|
1925
|
-
headers:
|
|
2562
|
+
headers: await getPostHeaders(),
|
|
1926
2563
|
body: JSON.stringify(payload),
|
|
1927
2564
|
});
|
|
1928
2565
|
const text = await handleResponse(resp);
|
|
1929
2566
|
return { content: [{ type: "text", text }] };
|
|
1930
2567
|
});
|
|
1931
2568
|
registerTool("update_ticket_state", {
|
|
2569
|
+
annotations: {
|
|
2570
|
+
readOnlyHint: false,
|
|
2571
|
+
destructiveHint: false,
|
|
2572
|
+
idempotentHint: false,
|
|
2573
|
+
openWorldHint: true,
|
|
2574
|
+
},
|
|
1932
2575
|
description: "Update workflow state timestamps on a tracked ticket. Each specified field is set to the current UTC timestamp on the server. " +
|
|
1933
2576
|
"Valid field names: 'critique_called', 'critique_answered', 'clarify_called', 'clarify_answered', 'plan_generated', 'implemented', 'reimplement_called'. " +
|
|
1934
2577
|
"The ticket must already be tracked (via track_ticket) or a 404 error is returned. " +
|
|
@@ -1942,13 +2585,19 @@ registerTool("update_ticket_state", {
|
|
|
1942
2585
|
const payload = { repo_name: REPO_NAME, fields };
|
|
1943
2586
|
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/state`), {
|
|
1944
2587
|
method: "PUT",
|
|
1945
|
-
headers:
|
|
2588
|
+
headers: await getPostHeaders(),
|
|
1946
2589
|
body: JSON.stringify(payload),
|
|
1947
2590
|
});
|
|
1948
2591
|
const text = await handleResponse(resp);
|
|
1949
2592
|
return { content: [{ type: "text", text }] };
|
|
1950
2593
|
});
|
|
1951
2594
|
registerTool("get_ticket_state", {
|
|
2595
|
+
annotations: {
|
|
2596
|
+
readOnlyHint: true,
|
|
2597
|
+
destructiveHint: false,
|
|
2598
|
+
idempotentHint: true,
|
|
2599
|
+
openWorldHint: true,
|
|
2600
|
+
},
|
|
1952
2601
|
description: "Retrieve workflow state timestamps and artifact existence flags for a tracked ticket. " +
|
|
1953
2602
|
"Returns timestamps for each state field (critique_called, critique_answered, clarify_called, clarify_answered, plan_generated, implemented, reimplement_called) " +
|
|
1954
2603
|
"and boolean flags indicating whether artifacts exist (has_clarifying_questions, has_critique, has_plan). " +
|
|
@@ -1959,7 +2608,7 @@ registerTool("get_ticket_state", {
|
|
|
1959
2608
|
},
|
|
1960
2609
|
}, async ({ ticket_number }) => {
|
|
1961
2610
|
const url = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/state`, { repo_name: REPO_NAME });
|
|
1962
|
-
const resp = await fetch(url, { headers:
|
|
2611
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
1963
2612
|
const text = await handleResponse(resp);
|
|
1964
2613
|
return { content: [{ type: "text", text }] };
|
|
1965
2614
|
});
|
|
@@ -1967,6 +2616,12 @@ registerTool("get_ticket_state", {
|
|
|
1967
2616
|
// Jira Transitions
|
|
1968
2617
|
// ---------------------------------------------------------------------------
|
|
1969
2618
|
registerTool("get_jira_transitions", {
|
|
2619
|
+
annotations: {
|
|
2620
|
+
readOnlyHint: true,
|
|
2621
|
+
destructiveHint: false,
|
|
2622
|
+
idempotentHint: true,
|
|
2623
|
+
openWorldHint: true,
|
|
2624
|
+
},
|
|
1970
2625
|
description: "List available Jira workflow transitions for a ticket. Returns each transition's id, name, and target status. " +
|
|
1971
2626
|
"Use this to discover what status changes are possible for a given ticket. " +
|
|
1972
2627
|
"The repo_name is automatically injected from the configured environment.",
|
|
@@ -1975,11 +2630,17 @@ registerTool("get_jira_transitions", {
|
|
|
1975
2630
|
},
|
|
1976
2631
|
}, async ({ ticket_number }) => {
|
|
1977
2632
|
const url = buildGetUrl(`/tickets/${encodeURIComponent(ticket_number)}/jira-transitions`, { repo_name: REPO_NAME });
|
|
1978
|
-
const resp = await fetch(url, { headers:
|
|
2633
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
1979
2634
|
const text = await handleResponse(resp);
|
|
1980
2635
|
return { content: [{ type: "text", text }] };
|
|
1981
2636
|
});
|
|
1982
2637
|
registerTool("update_jira_status", {
|
|
2638
|
+
annotations: {
|
|
2639
|
+
readOnlyHint: false,
|
|
2640
|
+
destructiveHint: true,
|
|
2641
|
+
idempotentHint: true,
|
|
2642
|
+
openWorldHint: true,
|
|
2643
|
+
},
|
|
1983
2644
|
description: "Transition a Jira ticket to a specified target status by executing a workflow transition. " +
|
|
1984
2645
|
"Provide either target_status (matched case-insensitively against available transitions) or transition_id (used directly). " +
|
|
1985
2646
|
"If transition_id is provided, it takes precedence over target_status. " +
|
|
@@ -2000,16 +2661,24 @@ registerTool("update_jira_status", {
|
|
|
2000
2661
|
payload.transition_id = transition_id;
|
|
2001
2662
|
const resp = await fetch(buildUrl(`/tickets/${encodeURIComponent(ticket_number)}/jira-status`), {
|
|
2002
2663
|
method: "PUT",
|
|
2003
|
-
headers:
|
|
2664
|
+
headers: await getPostHeaders(),
|
|
2004
2665
|
body: JSON.stringify(payload),
|
|
2005
2666
|
});
|
|
2006
2667
|
const text = await handleResponse(resp);
|
|
2007
2668
|
return { content: [{ type: "text", text }] };
|
|
2008
2669
|
});
|
|
2009
2670
|
registerTool("resolve_target_status", {
|
|
2010
|
-
|
|
2011
|
-
|
|
2671
|
+
annotations: {
|
|
2672
|
+
readOnlyHint: false,
|
|
2673
|
+
destructiveHint: false,
|
|
2674
|
+
idempotentHint: true,
|
|
2675
|
+
openWorldHint: true,
|
|
2676
|
+
},
|
|
2677
|
+
description: "Ask an LLM agent to CHOOSE the project's post-PR target Jira status, and cache that choice per project. " +
|
|
2678
|
+
"The agent selects the single workflow status that best represents 'code committed via PR but not yet tested.' " +
|
|
2012
2679
|
"Results are cached per-project — subsequent calls return the cached value unless force_rerun is true. " +
|
|
2680
|
+
"This does NOT list all available transitions — use `get_jira_transitions` for the full transition list. " +
|
|
2681
|
+
"This also does NOT move the ticket — use `update_jira_status` to actually perform the status transition. " +
|
|
2013
2682
|
"Requires a ticket_number to fetch available transitions from Jira. " +
|
|
2014
2683
|
"The repo_name is automatically injected from the configured environment.",
|
|
2015
2684
|
inputSchema: {
|
|
@@ -2025,7 +2694,7 @@ registerTool("resolve_target_status", {
|
|
|
2025
2694
|
payload.force_rerun = force_rerun;
|
|
2026
2695
|
const resp = await fetch(buildUrl("/resolve-target-status"), {
|
|
2027
2696
|
method: "POST",
|
|
2028
|
-
headers:
|
|
2697
|
+
headers: await getPostHeaders(),
|
|
2029
2698
|
body: JSON.stringify(payload),
|
|
2030
2699
|
});
|
|
2031
2700
|
const text = await handleResponse(resp);
|
|
@@ -2044,8 +2713,16 @@ const VALID_CONFIG_FIELDS = [
|
|
|
2044
2713
|
"post_pr_target_status", "ci_check_config", "ci_followup_config",
|
|
2045
2714
|
"allow_mutating_smoke_ops",
|
|
2046
2715
|
"selected_mcp_slugs",
|
|
2716
|
+
"base_branch",
|
|
2717
|
+
"tiered_execution",
|
|
2047
2718
|
].join(", ");
|
|
2048
2719
|
registerTool("list_config_fields", {
|
|
2720
|
+
annotations: {
|
|
2721
|
+
readOnlyHint: true,
|
|
2722
|
+
destructiveHint: false,
|
|
2723
|
+
idempotentHint: true,
|
|
2724
|
+
openWorldHint: true,
|
|
2725
|
+
},
|
|
2049
2726
|
description: "List all configurable fields available for reading and updating via the Bridge API. " +
|
|
2050
2727
|
"Returns each field's name and a description of its purpose. No database values are returned — " +
|
|
2051
2728
|
"use get_config_field to read a specific field's current value. " +
|
|
@@ -2053,11 +2730,17 @@ registerTool("list_config_fields", {
|
|
|
2053
2730
|
inputSchema: {},
|
|
2054
2731
|
}, async () => {
|
|
2055
2732
|
const url = buildGetUrl("/config-fields", { repo_name: REPO_NAME });
|
|
2056
|
-
const resp = await fetch(url, { headers:
|
|
2733
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
2057
2734
|
const text = await handleResponse(resp);
|
|
2058
2735
|
return { content: [{ type: "text", text }] };
|
|
2059
2736
|
});
|
|
2060
2737
|
registerTool("get_my_role", {
|
|
2738
|
+
annotations: {
|
|
2739
|
+
readOnlyHint: true,
|
|
2740
|
+
destructiveHint: false,
|
|
2741
|
+
idempotentHint: true,
|
|
2742
|
+
openWorldHint: true,
|
|
2743
|
+
},
|
|
2061
2744
|
description: "Check the role and auth source for the current API key. " +
|
|
2062
2745
|
"Returns JSON with {role: \"admin\" | \"member\" | null, source: \"user_access\" | \"legacy\"}. " +
|
|
2063
2746
|
"Use this to check if the current key has admin permissions before attempting configuration updates " +
|
|
@@ -2065,11 +2748,17 @@ registerTool("get_my_role", {
|
|
|
2065
2748
|
inputSchema: {},
|
|
2066
2749
|
}, async () => {
|
|
2067
2750
|
const url = buildGetUrl("/my-role", { repo_name: REPO_NAME });
|
|
2068
|
-
const resp = await fetch(url, { headers:
|
|
2751
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
2069
2752
|
const text = await handleResponse(resp);
|
|
2070
2753
|
return { content: [{ type: "text", text }] };
|
|
2071
2754
|
});
|
|
2072
2755
|
registerTool("get_config_field", {
|
|
2756
|
+
annotations: {
|
|
2757
|
+
readOnlyHint: true,
|
|
2758
|
+
destructiveHint: false,
|
|
2759
|
+
idempotentHint: true,
|
|
2760
|
+
openWorldHint: true,
|
|
2761
|
+
},
|
|
2073
2762
|
description: "Read the current value and metadata for a specific configuration field. " +
|
|
2074
2763
|
"Returns the field's current database value (or null if not set), along with a description of its purpose and examples of helpful content. " +
|
|
2075
2764
|
"Use this before update_config_field to understand the current state and build upon it rather than overwriting blindly.",
|
|
@@ -2078,11 +2767,17 @@ registerTool("get_config_field", {
|
|
|
2078
2767
|
},
|
|
2079
2768
|
}, async ({ field_name }) => {
|
|
2080
2769
|
const url = buildGetUrl(`/config-field/${encodeURIComponent(field_name)}`, { repo_name: REPO_NAME });
|
|
2081
|
-
const resp = await fetch(url, { headers:
|
|
2770
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
2082
2771
|
const text = await handleResponse(resp);
|
|
2083
2772
|
return { content: [{ type: "text", text }] };
|
|
2084
2773
|
});
|
|
2085
2774
|
registerTool("update_config_field", {
|
|
2775
|
+
annotations: {
|
|
2776
|
+
readOnlyHint: false,
|
|
2777
|
+
destructiveHint: true,
|
|
2778
|
+
idempotentHint: true,
|
|
2779
|
+
openWorldHint: true,
|
|
2780
|
+
},
|
|
2086
2781
|
description: "Update a specific configuration field in the Bridge database. " +
|
|
2087
2782
|
"These fields control LLM behavior during code review, planning, and documentation. " +
|
|
2088
2783
|
"Always call get_config_field first to read the current value and build upon it. " +
|
|
@@ -2094,6 +2789,11 @@ registerTool("update_config_field", {
|
|
|
2094
2789
|
"The selected_mcp_slugs field takes a JSON array of supported MCP validation manual slug strings " +
|
|
2095
2790
|
"(e.g. [\"b2c-commerce-developer\", \"playwright-mcp\", \"pwa-kit-mcp\"]) — pass an array of strings, " +
|
|
2096
2791
|
"not a comma-delimited string; an empty array clears the selection. " +
|
|
2792
|
+
"The base_branch field is a string/null field controlling the development base branch used by PR " +
|
|
2793
|
+
"creation (/create-pr) and start-tickets worktree creation; an empty/null value clears it and " +
|
|
2794
|
+
"automations fall back to 'main'. " +
|
|
2795
|
+
"The tiered_execution field is a string enum with valid values 'off', 'claude_code_only', and " +
|
|
2796
|
+
"'all_capable'; omitting/clearing the value resolves to the backend default 'claude_code_only'. " +
|
|
2097
2797
|
"For string fields, omit both value and file_path to set the field to NULL (clearing it). " +
|
|
2098
2798
|
"Scalar boolean fields are NOT NULL and have no clear/null state: omitting the value writes false " +
|
|
2099
2799
|
"(matching the API-layer coercion), so pass true/false explicitly."),
|
|
@@ -2124,7 +2824,7 @@ registerTool("update_config_field", {
|
|
|
2124
2824
|
const arrayValue = value === undefined ? [] : value;
|
|
2125
2825
|
const resp = await fetch(buildUrl(`/config-field/${encodeURIComponent(field_name)}`), {
|
|
2126
2826
|
method: "PUT",
|
|
2127
|
-
headers:
|
|
2827
|
+
headers: await getPostHeaders(),
|
|
2128
2828
|
body: JSON.stringify({ repo_name: REPO_NAME, value: arrayValue }),
|
|
2129
2829
|
});
|
|
2130
2830
|
const text = await handleResponse(resp);
|
|
@@ -2173,7 +2873,7 @@ registerTool("update_config_field", {
|
|
|
2173
2873
|
}
|
|
2174
2874
|
const resp = await fetch(buildUrl(`/config-field/${encodeURIComponent(field_name)}`), {
|
|
2175
2875
|
method: "PUT",
|
|
2176
|
-
headers:
|
|
2876
|
+
headers: await getPostHeaders(),
|
|
2177
2877
|
body: JSON.stringify({ repo_name: REPO_NAME, value: boolValue }),
|
|
2178
2878
|
});
|
|
2179
2879
|
const text = await handleResponse(resp);
|
|
@@ -2191,7 +2891,7 @@ registerTool("update_config_field", {
|
|
|
2191
2891
|
}
|
|
2192
2892
|
const resp = await fetch(buildUrl(`/config-field/${encodeURIComponent(field_name)}`), {
|
|
2193
2893
|
method: "PUT",
|
|
2194
|
-
headers:
|
|
2894
|
+
headers: await getPostHeaders(),
|
|
2195
2895
|
body: JSON.stringify({ repo_name: REPO_NAME, value: finalValue }),
|
|
2196
2896
|
});
|
|
2197
2897
|
const text = await handleResponse(resp);
|
|
@@ -2254,8 +2954,14 @@ function formatDeepResearchStatus(body, taskId) {
|
|
|
2254
2954
|
return `Status: ${body.status}${elapsed}${reason} (task_id: ${taskId}). Try again in a minute.`;
|
|
2255
2955
|
}
|
|
2256
2956
|
registerTool("request_deep_research", {
|
|
2257
|
-
|
|
2258
|
-
|
|
2957
|
+
annotations: {
|
|
2958
|
+
readOnlyHint: false,
|
|
2959
|
+
destructiveHint: false,
|
|
2960
|
+
idempotentHint: false,
|
|
2961
|
+
openWorldHint: true,
|
|
2962
|
+
},
|
|
2963
|
+
description: "START async deep research on a technical topic using AI-powered web search. " +
|
|
2964
|
+
"Returns a task_id for tracking the research progress; the matching get_deep_research tool retrieves the finished report later (unless you set wait_for_result). " +
|
|
2259
2965
|
"\n\n" +
|
|
2260
2966
|
"WHEN TO USE: Use this tool when you need to investigate unfamiliar technologies, " +
|
|
2261
2967
|
"gather implementation guidance across multiple sources, research best practices for a complex topic, " +
|
|
@@ -2297,7 +3003,7 @@ registerTool("request_deep_research", {
|
|
|
2297
3003
|
submitPayload.ticket_number = ticket_number;
|
|
2298
3004
|
const submitResp = await fetch(buildUrl("/deep-research"), {
|
|
2299
3005
|
method: "POST",
|
|
2300
|
-
headers:
|
|
3006
|
+
headers: await getPostHeaders(),
|
|
2301
3007
|
body: JSON.stringify(submitPayload),
|
|
2302
3008
|
});
|
|
2303
3009
|
if (!submitResp.ok) {
|
|
@@ -2323,7 +3029,7 @@ registerTool("request_deep_research", {
|
|
|
2323
3029
|
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
2324
3030
|
console.error(`Deep research in progress... (elapsed: ${elapsed}s, status: ${lastStatus})`);
|
|
2325
3031
|
const statusUrl = buildGetUrl(`/deep-research/${taskId}/status`, { repo_name: REPO_NAME });
|
|
2326
|
-
const statusResp = await fetch(statusUrl, { headers:
|
|
3032
|
+
const statusResp = await fetch(statusUrl, { headers: await getGetHeaders() });
|
|
2327
3033
|
if (!statusResp.ok) {
|
|
2328
3034
|
const errorText = await handleResponse(statusResp);
|
|
2329
3035
|
return { content: [{ type: "text", text: JSON.stringify({ error: "INTERNAL_ERROR", status: 500, message: `Error polling deep research status: ${errorText}` }) }] };
|
|
@@ -2360,7 +3066,7 @@ registerTool("request_deep_research", {
|
|
|
2360
3066
|
}
|
|
2361
3067
|
// 3. Retrieve the result
|
|
2362
3068
|
const resultUrl = buildGetUrl(`/deep-research/${taskId}/result`, { repo_name: REPO_NAME });
|
|
2363
|
-
const resultResp = await fetch(resultUrl, { headers:
|
|
3069
|
+
const resultResp = await fetch(resultUrl, { headers: await getGetHeaders() });
|
|
2364
3070
|
if (!resultResp.ok) {
|
|
2365
3071
|
const errorText = await handleResponse(resultResp);
|
|
2366
3072
|
return { content: [{ type: "text", text: JSON.stringify({ error: "INTERNAL_ERROR", status: 500, message: `Error retrieving deep research result: ${errorText}` }) }] };
|
|
@@ -2369,15 +3075,22 @@ registerTool("request_deep_research", {
|
|
|
2369
3075
|
// 4. Optionally save to file
|
|
2370
3076
|
if (save_locally) {
|
|
2371
3077
|
const slug = slugify(query);
|
|
2372
|
-
const note = await saveLocally(getDocsPath("deep-research"), `${slug}-${taskId}.md`, resultText);
|
|
3078
|
+
const note = await saveLocally(await getDocsPath("deep-research"), `${slug}-${taskId}.md`, resultText);
|
|
2373
3079
|
resultText += note;
|
|
2374
3080
|
}
|
|
2375
3081
|
return { content: [{ type: "text", text: resultText }] };
|
|
2376
3082
|
});
|
|
2377
3083
|
registerTool("get_deep_research", {
|
|
2378
|
-
|
|
3084
|
+
annotations: {
|
|
3085
|
+
readOnlyHint: true,
|
|
3086
|
+
destructiveHint: false,
|
|
3087
|
+
idempotentHint: true,
|
|
3088
|
+
openWorldHint: true,
|
|
3089
|
+
},
|
|
3090
|
+
description: "RETRIEVE the result of a previously submitted deep research request. This tool only fetches an existing/in-progress result — it does NOT start or trigger new research. " +
|
|
3091
|
+
"If you have not submitted a research request yet (or you need a new one), call `request_deep_research` first; it starts the async research and this `get_deep_research` tool retrieves the result. " +
|
|
2379
3092
|
"Returns the full markdown research report if the task is completed, " +
|
|
2380
|
-
"or a structured status response
|
|
3093
|
+
"or a structured status response (still processing / failed / not-found) if the report is not ready yet — that means research has not finished, not that this tool failed. " +
|
|
2381
3094
|
"Use this after calling request_deep_research with wait_for_result=false.",
|
|
2382
3095
|
inputSchema: {
|
|
2383
3096
|
task_id: z.number().describe("The task ID returned by request_deep_research."),
|
|
@@ -2388,7 +3101,7 @@ registerTool("get_deep_research", {
|
|
|
2388
3101
|
}, async ({ task_id, query_slug, save_locally }) => {
|
|
2389
3102
|
// 1. Check status
|
|
2390
3103
|
const statusUrl = buildGetUrl(`/deep-research/${task_id}/status`, { repo_name: REPO_NAME });
|
|
2391
|
-
const statusResp = await fetch(statusUrl, { headers:
|
|
3104
|
+
const statusResp = await fetch(statusUrl, { headers: await getGetHeaders() });
|
|
2392
3105
|
if (!statusResp.ok) {
|
|
2393
3106
|
const errorText = await handleResponse(statusResp);
|
|
2394
3107
|
return { content: [{ type: "text", text: errorText }] };
|
|
@@ -2412,7 +3125,7 @@ registerTool("get_deep_research", {
|
|
|
2412
3125
|
}
|
|
2413
3126
|
// 2. Retrieve the result
|
|
2414
3127
|
const resultUrl = buildGetUrl(`/deep-research/${task_id}/result`, { repo_name: REPO_NAME });
|
|
2415
|
-
const resultResp = await fetch(resultUrl, { headers:
|
|
3128
|
+
const resultResp = await fetch(resultUrl, { headers: await getGetHeaders() });
|
|
2416
3129
|
if (!resultResp.ok) {
|
|
2417
3130
|
const errorText = await handleResponse(resultResp);
|
|
2418
3131
|
return { content: [{ type: "text", text: errorText }] };
|
|
@@ -2421,11 +3134,15 @@ registerTool("get_deep_research", {
|
|
|
2421
3134
|
// 3. Optionally save to file
|
|
2422
3135
|
if (save_locally) {
|
|
2423
3136
|
const slug = query_slug || "research";
|
|
2424
|
-
const note = await saveLocally(getDocsPath("deep-research"), `${slug}-${task_id}.md`, resultText);
|
|
3137
|
+
const note = await saveLocally(await getDocsPath("deep-research"), `${slug}-${task_id}.md`, resultText);
|
|
2425
3138
|
resultText += note;
|
|
2426
3139
|
}
|
|
2427
3140
|
return { content: [{ type: "text", text: resultText }] };
|
|
2428
3141
|
});
|
|
3142
|
+
// ``BrainstormResultRow`` / ``BrainstormResultEnvelope`` types,
|
|
3143
|
+
// ``sanitizeProviderForFilename``, and the brainstorm filename/save helpers now
|
|
3144
|
+
// live in ``./brainstorm-files.ts`` so they can be unit-tested directly (see the
|
|
3145
|
+
// import near the top of this file).
|
|
2429
3146
|
const BRAINSTORM_TERMINAL_STATUSES = new Set([
|
|
2430
3147
|
"completed",
|
|
2431
3148
|
"failed",
|
|
@@ -2434,16 +3151,6 @@ const BRAINSTORM_TERMINAL_STATUSES = new Set([
|
|
|
2434
3151
|
function isBrainstormTerminalStatus(status) {
|
|
2435
3152
|
return BRAINSTORM_TERMINAL_STATUSES.has(status);
|
|
2436
3153
|
}
|
|
2437
|
-
function sanitizeProviderForFilename(provider) {
|
|
2438
|
-
// Defensive: allow letters/digits/hyphen/underscore only; collapse runs and
|
|
2439
|
-
// trim trailing punctuation. Falls back to "provider" for an empty result.
|
|
2440
|
-
const cleaned = provider
|
|
2441
|
-
.toLowerCase()
|
|
2442
|
-
.replace(/[^a-z0-9_-]+/g, "-")
|
|
2443
|
-
.replace(/-+/g, "-")
|
|
2444
|
-
.replace(/^-+|-+$/g, "");
|
|
2445
|
-
return cleaned || "provider";
|
|
2446
|
-
}
|
|
2447
3154
|
async function pollBrainstormUntilTerminal(brainstormId, repoName) {
|
|
2448
3155
|
const startTime = Date.now();
|
|
2449
3156
|
const MAX_TIMEOUT_MS = 15 * 60 * 1000;
|
|
@@ -2452,7 +3159,7 @@ async function pollBrainstormUntilTerminal(brainstormId, repoName) {
|
|
|
2452
3159
|
while (Date.now() - startTime < MAX_TIMEOUT_MS) {
|
|
2453
3160
|
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
2454
3161
|
const statusUrl = buildGetUrl(`/brainstorms/${brainstormId}/status`, { repo_name: repoName });
|
|
2455
|
-
const statusResp = await fetch(statusUrl, { headers:
|
|
3162
|
+
const statusResp = await fetch(statusUrl, { headers: await getGetHeaders() });
|
|
2456
3163
|
if (!statusResp.ok) {
|
|
2457
3164
|
return latest;
|
|
2458
3165
|
}
|
|
@@ -2467,27 +3174,13 @@ async function pollBrainstormUntilTerminal(brainstormId, repoName) {
|
|
|
2467
3174
|
}
|
|
2468
3175
|
return latest;
|
|
2469
3176
|
}
|
|
2470
|
-
async function saveBrainstormResultsLocally(envelope) {
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
}
|
|
2478
|
-
const providerSegment = sanitizeProviderForFilename(row.provider);
|
|
2479
|
-
const filename = `${envelope.brainstorm_id}-${providerSegment}.md`;
|
|
2480
|
-
const filePath = path.join(dir, filename);
|
|
2481
|
-
try {
|
|
2482
|
-
await mkdir(dir, { recursive: true });
|
|
2483
|
-
await writeFile(filePath, markdown, "utf-8");
|
|
2484
|
-
savedPaths.push(filePath);
|
|
2485
|
-
}
|
|
2486
|
-
catch {
|
|
2487
|
-
// Skip rows that fail to write — never block the response.
|
|
2488
|
-
}
|
|
2489
|
-
}
|
|
2490
|
-
return savedPaths;
|
|
3177
|
+
async function saveBrainstormResultsLocally(envelope, subject) {
|
|
3178
|
+
// Resolve the docs directory here, then delegate the write loop and filename
|
|
3179
|
+
// construction to the pure, unit-tested helper in ``./brainstorm-files.ts``.
|
|
3180
|
+
// When ``subject`` (the original ``task_description``) is provided, files get
|
|
3181
|
+
// semantic names; otherwise they fall back to the UUID-only pattern.
|
|
3182
|
+
const dir = await getDocsPath("brainstorm");
|
|
3183
|
+
return saveBrainstormResultsToDir(envelope, dir, subject);
|
|
2491
3184
|
}
|
|
2492
3185
|
function formatBrainstormToolResponse(envelope, savedPaths) {
|
|
2493
3186
|
const lines = [];
|
|
@@ -2516,14 +3209,28 @@ function formatBrainstormToolResponse(envelope, savedPaths) {
|
|
|
2516
3209
|
return lines.join("\n");
|
|
2517
3210
|
}
|
|
2518
3211
|
registerTool("request_brainstorm", {
|
|
2519
|
-
|
|
3212
|
+
annotations: {
|
|
3213
|
+
readOnlyHint: false,
|
|
3214
|
+
destructiveHint: false,
|
|
3215
|
+
idempotentHint: false,
|
|
3216
|
+
openWorldHint: true,
|
|
3217
|
+
},
|
|
3218
|
+
description: "START an async brainstorm that fans out the task to two opinion-provider LLMs " +
|
|
2520
3219
|
"(default: OpenAI + Gemini) and then runs a synthesizer pass over the completed opinions. " +
|
|
2521
|
-
"Returns a brainstorm_id
|
|
3220
|
+
"Returns a brainstorm_id; the matching get_brainstorm tool retrieves the finished result later (unless you set wait_for_result). " +
|
|
2522
3221
|
"\n\n" +
|
|
2523
3222
|
"BEHAVIOR: By default, returns immediately with a brainstorm_id. Set wait_for_result=true " +
|
|
2524
3223
|
"to poll for terminal status (up to 15 minutes), then retrieve and optionally save the result. " +
|
|
2525
|
-
"When save_locally=true (default), each provider's markdown is written
|
|
2526
|
-
"
|
|
3224
|
+
"When save_locally=true (default), each provider's markdown is written with a semantic " +
|
|
3225
|
+
"filename derived from task_description: " +
|
|
3226
|
+
"BAPI_DOCS_DIR/brainstorm/{slugified-task-description}-{short_brainstorm_id}-{provider}.md, " +
|
|
3227
|
+
"and the synthesizer row follows the same pattern " +
|
|
3228
|
+
"({slugified-task-description}-{short_brainstorm_id}-synthesizer.md). " +
|
|
3229
|
+
"(If task_description is empty or slugifies to nothing, it falls back to {brainstorm_id}-{provider}.md.) " +
|
|
3230
|
+
"\n\n" +
|
|
3231
|
+
"DESIGN MODE: Set design=true to run the brainstorm as web-page/UI design ideation focused on " +
|
|
3232
|
+
"visual appeal and conversion, rather than the default software-engineering framing. Omit the " +
|
|
3233
|
+
"design field unless you want design mode (absent is treated as false).",
|
|
2527
3234
|
inputSchema: {
|
|
2528
3235
|
task_description: z.string().describe("Free-form description of the task to brainstorm about. Sent verbatim — " +
|
|
2529
3236
|
"this tool does NOT read task_description from a file."),
|
|
@@ -2536,12 +3243,14 @@ registerTool("request_brainstorm", {
|
|
|
2536
3243
|
wait_for_result: z.boolean().optional().describe("When true, polls until every row reaches a terminal status (max 15 minutes), " +
|
|
2537
3244
|
"then returns the full result envelope. When false (default), returns immediately."),
|
|
2538
3245
|
save_locally: z.boolean().optional().describe("When true (default), writes each provider's markdown to BAPI_DOCS_DIR/brainstorm/ " +
|
|
2539
|
-
"after the result is fetched."
|
|
3246
|
+
"after the result is fetched. Request-time saves use semantic filenames derived from " +
|
|
3247
|
+
"task_description (slugified) plus a short brainstorm-id segment."),
|
|
2540
3248
|
prior_brainstorm_id: z.string().optional().describe("Optional brainstorm_id from an earlier brainstorm to refine. " +
|
|
2541
3249
|
"When provided, the new brainstorm receives the prior brainstorm's " +
|
|
2542
3250
|
"synthesizer markdown, or a completed opinion-provider fallback, as prior context."),
|
|
3251
|
+
design: z.boolean().optional().describe("Set to true for web-page/UI design ideation focused on visual appeal and conversion. Omit this field when not requesting design mode; absent is treated as false."),
|
|
2543
3252
|
},
|
|
2544
|
-
}, async ({ task_description, repo_name, ticket_number, providers, concerns, wait_for_result, save_locally, prior_brainstorm_id, }) => {
|
|
3253
|
+
}, async ({ task_description, repo_name, ticket_number, providers, concerns, wait_for_result, save_locally, prior_brainstorm_id, design, }) => {
|
|
2545
3254
|
const effectiveRepo = repo_name && repo_name.length > 0 ? repo_name : REPO_NAME;
|
|
2546
3255
|
const effectiveProviders = providers !== undefined ? providers : ["openai", "gemini"];
|
|
2547
3256
|
const shouldWait = wait_for_result === true;
|
|
@@ -2558,9 +3267,11 @@ registerTool("request_brainstorm", {
|
|
|
2558
3267
|
if (prior_brainstorm_id) {
|
|
2559
3268
|
submitPayload.prior_brainstorm_request_id = prior_brainstorm_id;
|
|
2560
3269
|
}
|
|
3270
|
+
if (design)
|
|
3271
|
+
submitPayload.design = true;
|
|
2561
3272
|
const submitResp = await fetch(buildUrl("/brainstorms"), {
|
|
2562
3273
|
method: "POST",
|
|
2563
|
-
headers:
|
|
3274
|
+
headers: await getPostHeaders(),
|
|
2564
3275
|
body: JSON.stringify(submitPayload),
|
|
2565
3276
|
});
|
|
2566
3277
|
if (!submitResp.ok) {
|
|
@@ -2586,7 +3297,7 @@ registerTool("request_brainstorm", {
|
|
|
2586
3297
|
};
|
|
2587
3298
|
}
|
|
2588
3299
|
const resultUrl = buildGetUrl(`/brainstorms/${submitBody.brainstorm_id}/result`, { repo_name: effectiveRepo });
|
|
2589
|
-
const resultResp = await fetch(resultUrl, { headers:
|
|
3300
|
+
const resultResp = await fetch(resultUrl, { headers: await getGetHeaders() });
|
|
2590
3301
|
if (!resultResp.ok) {
|
|
2591
3302
|
const errorText = await handleResponse(resultResp);
|
|
2592
3303
|
return { content: [{ type: "text", text: errorText }] };
|
|
@@ -2594,7 +3305,9 @@ registerTool("request_brainstorm", {
|
|
|
2594
3305
|
const envelope = (await resultResp.json());
|
|
2595
3306
|
let savedPaths = [];
|
|
2596
3307
|
if (shouldSave) {
|
|
2597
|
-
|
|
3308
|
+
// Request-time saves use semantic filenames derived from the in-scope
|
|
3309
|
+
// ``task_description``.
|
|
3310
|
+
savedPaths = await saveBrainstormResultsLocally(envelope, task_description);
|
|
2598
3311
|
}
|
|
2599
3312
|
return {
|
|
2600
3313
|
content: [{
|
|
@@ -2604,21 +3317,33 @@ registerTool("request_brainstorm", {
|
|
|
2604
3317
|
};
|
|
2605
3318
|
});
|
|
2606
3319
|
registerTool("get_brainstorm", {
|
|
2607
|
-
|
|
2608
|
-
|
|
3320
|
+
annotations: {
|
|
3321
|
+
readOnlyHint: true,
|
|
3322
|
+
destructiveHint: false,
|
|
3323
|
+
idempotentHint: true,
|
|
3324
|
+
openWorldHint: true,
|
|
3325
|
+
},
|
|
3326
|
+
description: "RETRIEVE the result envelope for a previously submitted brainstorm by brainstorm_id. This tool only fetches an existing/in-progress result — it does NOT start or trigger a new brainstorm. " +
|
|
3327
|
+
"If you have not submitted a brainstorm yet (or you need a new one), call `request_brainstorm` first; it starts the async brainstorm and returns the brainstorm_id this `get_brainstorm` tool retrieves by. " +
|
|
3328
|
+
"Returns all rows (opinion providers + synthesizer), including error_kind for every row; a not-found / still-processing response means the brainstorm has not completed, not that this tool failed. " +
|
|
2609
3329
|
"When save_locally=true (default), writes each provider's markdown to " +
|
|
2610
3330
|
"BAPI_DOCS_DIR/brainstorm/{brainstorm_id}-{provider}.md (including the synthesizer file). " +
|
|
3331
|
+
"Retrieval uses this UUID-only filename (not the semantic task-description name that " +
|
|
3332
|
+
"request_brainstorm uses) because the original task description is not available in the " +
|
|
3333
|
+
"result envelope on retrieval. " +
|
|
2611
3334
|
"DB-only retrieval — never falls back to Jira attachments.",
|
|
2612
3335
|
inputSchema: {
|
|
2613
3336
|
brainstorm_id: z.string().describe("The brainstorm_id (UUID) returned by request_brainstorm."),
|
|
2614
3337
|
repo_name: z.string().optional().describe("Repository name. Defaults to BAPI_REPO_NAME from the environment."),
|
|
2615
|
-
save_locally: z.boolean().optional().describe("When true (default), writes each provider's markdown to BAPI_DOCS_DIR/brainstorm/."
|
|
3338
|
+
save_locally: z.boolean().optional().describe("When true (default), writes each provider's markdown to BAPI_DOCS_DIR/brainstorm/. " +
|
|
3339
|
+
"Retrieval-time saves intentionally use the UUID-only fallback filename " +
|
|
3340
|
+
"({brainstorm_id}-{provider}.md), not a task-description-derived semantic name."),
|
|
2616
3341
|
},
|
|
2617
3342
|
}, async ({ brainstorm_id, repo_name, save_locally }) => {
|
|
2618
3343
|
const effectiveRepo = repo_name && repo_name.length > 0 ? repo_name : REPO_NAME;
|
|
2619
3344
|
const shouldSave = save_locally !== false;
|
|
2620
3345
|
const resultUrl = buildGetUrl(`/brainstorms/${brainstorm_id}/result`, { repo_name: effectiveRepo });
|
|
2621
|
-
const resultResp = await fetch(resultUrl, { headers:
|
|
3346
|
+
const resultResp = await fetch(resultUrl, { headers: await getGetHeaders() });
|
|
2622
3347
|
if (!resultResp.ok) {
|
|
2623
3348
|
const errorText = await handleResponse(resultResp);
|
|
2624
3349
|
return { content: [{ type: "text", text: errorText }] };
|
|
@@ -2626,6 +3351,9 @@ registerTool("get_brainstorm", {
|
|
|
2626
3351
|
const envelope = (await resultResp.json());
|
|
2627
3352
|
let savedPaths = [];
|
|
2628
3353
|
if (shouldSave) {
|
|
3354
|
+
// ``get_brainstorm`` intentionally uses the UUID-only fallback: the result
|
|
3355
|
+
// envelope does not echo the original ``task_description``, so no semantic
|
|
3356
|
+
// subject is available here. (Documented asymmetry with ``request_brainstorm``.)
|
|
2629
3357
|
savedPaths = await saveBrainstormResultsLocally(envelope);
|
|
2630
3358
|
}
|
|
2631
3359
|
return {
|
|
@@ -2639,6 +3367,12 @@ registerTool("get_brainstorm", {
|
|
|
2639
3367
|
// VCS & CI Tools
|
|
2640
3368
|
// ---------------------------------------------------------------------------
|
|
2641
3369
|
registerTool("create_pull_request", {
|
|
3370
|
+
annotations: {
|
|
3371
|
+
readOnlyHint: false,
|
|
3372
|
+
destructiveHint: false,
|
|
3373
|
+
idempotentHint: true,
|
|
3374
|
+
openWorldHint: true,
|
|
3375
|
+
},
|
|
2642
3376
|
description: "Create a pull request on the configured VCS provider (GitHub or Bitbucket). " +
|
|
2643
3377
|
"Returns a structured response with {available, reason, action, detail}. " +
|
|
2644
3378
|
"If a PR already exists for the head branch, returns it with created=false. " +
|
|
@@ -2661,13 +3395,19 @@ registerTool("create_pull_request", {
|
|
|
2661
3395
|
payload.body = body;
|
|
2662
3396
|
const resp = await fetch(buildUrl("/vcs/pull-requests"), {
|
|
2663
3397
|
method: "POST",
|
|
2664
|
-
headers:
|
|
3398
|
+
headers: await getPostHeaders(),
|
|
2665
3399
|
body: JSON.stringify(payload),
|
|
2666
3400
|
});
|
|
2667
3401
|
const text = await handleResponse(resp);
|
|
2668
3402
|
return { content: [{ type: "text", text }] };
|
|
2669
3403
|
});
|
|
2670
3404
|
const resolveCiChecksTool = registerTool("resolve_ci_checks", {
|
|
3405
|
+
annotations: {
|
|
3406
|
+
readOnlyHint: false,
|
|
3407
|
+
destructiveHint: false,
|
|
3408
|
+
idempotentHint: true,
|
|
3409
|
+
openWorldHint: true,
|
|
3410
|
+
},
|
|
2671
3411
|
description: "Discover and classify CI checks for the configured repository. " +
|
|
2672
3412
|
"Queries GitHub Check Runs + Commit Statuses APIs (or Bitbucket Build Statuses), " +
|
|
2673
3413
|
"then uses Branch Protection API or LLM to determine which checks are required for merging. " +
|
|
@@ -2687,7 +3427,7 @@ const resolveCiChecksTool = registerTool("resolve_ci_checks", {
|
|
|
2687
3427
|
payload.force_rerun = force_rerun;
|
|
2688
3428
|
const resp = await fetch(buildUrl("/resolve-ci-checks"), {
|
|
2689
3429
|
method: "POST",
|
|
2690
|
-
headers:
|
|
3430
|
+
headers: await getPostHeaders(),
|
|
2691
3431
|
body: JSON.stringify(payload),
|
|
2692
3432
|
});
|
|
2693
3433
|
const text = await handleResponse(resp);
|
|
@@ -2704,6 +3444,12 @@ const resolveCiChecksTool = registerTool("resolve_ci_checks", {
|
|
|
2704
3444
|
return { content: [{ type: "text", text }] };
|
|
2705
3445
|
});
|
|
2706
3446
|
const pollCiChecksTool = registerTool("poll_ci_checks", {
|
|
3447
|
+
annotations: {
|
|
3448
|
+
readOnlyHint: true,
|
|
3449
|
+
destructiveHint: false,
|
|
3450
|
+
idempotentHint: true,
|
|
3451
|
+
openWorldHint: true,
|
|
3452
|
+
},
|
|
2707
3453
|
description: "Poll the current status of CI checks for a specific commit. " +
|
|
2708
3454
|
"Requires that resolve_ci_checks has been called first to populate the check configuration. " +
|
|
2709
3455
|
"Returns per-check status, all_complete, all_passed, and unknown_checks fields. " +
|
|
@@ -2718,7 +3464,7 @@ const pollCiChecksTool = registerTool("poll_ci_checks", {
|
|
|
2718
3464
|
repo_name: REPO_NAME,
|
|
2719
3465
|
commit_ref,
|
|
2720
3466
|
});
|
|
2721
|
-
const resp = await fetch(url, { headers:
|
|
3467
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
2722
3468
|
const text = await handleResponse(resp);
|
|
2723
3469
|
return { content: [{ type: "text", text }] };
|
|
2724
3470
|
});
|
|
@@ -2728,7 +3474,7 @@ const pollCiChecksTool = registerTool("poll_ci_checks", {
|
|
|
2728
3474
|
async function checkCiConfigAndDisablePoll() {
|
|
2729
3475
|
try {
|
|
2730
3476
|
const url = buildGetUrl("/config-field/ci_check_config", { repo_name: REPO_NAME });
|
|
2731
|
-
const resp = await fetch(url, { headers:
|
|
3477
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
2732
3478
|
if (resp.ok) {
|
|
2733
3479
|
const body = await resp.json();
|
|
2734
3480
|
const value = body.value;
|
|
@@ -2752,46 +3498,49 @@ async function checkCiConfigAndDisablePoll() {
|
|
|
2752
3498
|
// Check config before connecting
|
|
2753
3499
|
await checkCiConfigAndDisablePoll();
|
|
2754
3500
|
// ---------------------------------------------------------------------------
|
|
2755
|
-
// Custom pipeline loading (BAPI-275)
|
|
3501
|
+
// Custom pipeline loading (BAPI-275, deferred in BAPI-338)
|
|
2756
3502
|
// ---------------------------------------------------------------------------
|
|
2757
3503
|
//
|
|
2758
|
-
// Custom user pipelines
|
|
2759
|
-
//
|
|
2760
|
-
//
|
|
2761
|
-
//
|
|
2762
|
-
//
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
const customResult = await loadCustomPipelines(BAPI_PIPELINES_DIR, instructionsDir, BUNDLED_INSTRUCTIONS);
|
|
2766
|
-
for (const [key, pipeline] of Object.entries(customResult.pipelines)) {
|
|
2767
|
-
if (key in BUNDLED_PIPELINES) {
|
|
2768
|
-
console.error(`Warning: user pipeline "${key}" overrides bundled pipeline.`);
|
|
2769
|
-
}
|
|
2770
|
-
PIPELINES[key] = pipeline;
|
|
2771
|
-
}
|
|
2772
|
-
Object.assign(INSTRUCTIONS, customResult.instructions);
|
|
2773
|
-
userPipelineKeys = customResult.userPipelineKeys;
|
|
2774
|
-
}
|
|
3504
|
+
// Custom user pipelines are merged into PIPELINES + INSTRUCTIONS by
|
|
3505
|
+
// `ensureCustomPipelinesLoaded()` (defined above). It is NO LONGER run at module
|
|
3506
|
+
// load — the pipelines dir resolution depends on the project root, which may
|
|
3507
|
+
// come from MCP `roots/list` and is only available after the server connects.
|
|
3508
|
+
// Instead, every catalog-dependent handler (`list_pipelines`,
|
|
3509
|
+
// `get_pipeline_recipe`) and the orchestrator-deps builders await it first, so
|
|
3510
|
+
// they all observe the same final merged objects.
|
|
2775
3511
|
// ---------------------------------------------------------------------------
|
|
2776
3512
|
// Pipeline Recipe Tools
|
|
2777
3513
|
// ---------------------------------------------------------------------------
|
|
2778
3514
|
registerTool("get_docs_dir", {
|
|
3515
|
+
annotations: {
|
|
3516
|
+
readOnlyHint: true,
|
|
3517
|
+
destructiveHint: false,
|
|
3518
|
+
idempotentHint: true,
|
|
3519
|
+
openWorldHint: false,
|
|
3520
|
+
},
|
|
2779
3521
|
description: "Return the locally configured docs directory path (BAPI_DOCS_DIR, default docs/tmp). " +
|
|
2780
3522
|
"No parameters. Use this instead of reading the BAPI_DOCS_DIR environment variable directly, " +
|
|
2781
3523
|
"which requires shell access and may be blocked on some AI coding platforms.",
|
|
2782
3524
|
inputSchema: {},
|
|
2783
3525
|
}, async () => {
|
|
2784
|
-
return { content: [{ type: "text", text:
|
|
3526
|
+
return { content: [{ type: "text", text: await getDocsDir() }] };
|
|
2785
3527
|
});
|
|
2786
3528
|
registerTool("list_pipelines", {
|
|
3529
|
+
annotations: {
|
|
3530
|
+
readOnlyHint: true,
|
|
3531
|
+
destructiveHint: false,
|
|
3532
|
+
idempotentHint: true,
|
|
3533
|
+
openWorldHint: false,
|
|
3534
|
+
},
|
|
2787
3535
|
description: "List all available pipeline recipes with their names, descriptions, and required variables. " +
|
|
2788
3536
|
"No parameters. Use this to discover available pipelines before calling get_pipeline_recipe.",
|
|
2789
3537
|
inputSchema: {},
|
|
2790
3538
|
}, async () => {
|
|
3539
|
+
await ensureCustomPipelinesLoaded();
|
|
2791
3540
|
const list = Object.entries(PIPELINES).map(([key, pipeline]) => ({
|
|
2792
3541
|
name: key,
|
|
2793
3542
|
description: pipeline.description ?? "",
|
|
2794
|
-
variables: (pipeline.variables ?? []).filter((v) => v !== "docs_dir"),
|
|
3543
|
+
variables: (pipeline.variables ?? []).filter((v) => v !== "docs_dir" && v !== "idea_hash"),
|
|
2795
3544
|
source: userPipelineKeys.has(key) ? "user" : "bundled",
|
|
2796
3545
|
}));
|
|
2797
3546
|
return {
|
|
@@ -2799,6 +3548,12 @@ registerTool("list_pipelines", {
|
|
|
2799
3548
|
};
|
|
2800
3549
|
});
|
|
2801
3550
|
registerTool("get_pipeline_recipe", {
|
|
3551
|
+
annotations: {
|
|
3552
|
+
readOnlyHint: true,
|
|
3553
|
+
destructiveHint: false,
|
|
3554
|
+
idempotentHint: true,
|
|
3555
|
+
openWorldHint: false,
|
|
3556
|
+
},
|
|
2802
3557
|
description: "Retrieve a fully resolved pipeline recipe by name. Substitutes variables, resolves instruction " +
|
|
2803
3558
|
"file references to inline content, and returns an ordered array of executable steps. " +
|
|
2804
3559
|
"Each step is either an mcp_call (with tool name and params) or an agent_task (with instruction text). " +
|
|
@@ -2809,7 +3564,7 @@ registerTool("get_pipeline_recipe", {
|
|
|
2809
3564
|
.string()
|
|
2810
3565
|
.describe("Pipeline name (e.g. 'review-ticket', 'implement-ticket')"),
|
|
2811
3566
|
variables: z
|
|
2812
|
-
.record(z.string())
|
|
3567
|
+
.record(z.string(), z.string())
|
|
2813
3568
|
.optional()
|
|
2814
3569
|
.describe("Key-value pairs for variable substitution (e.g. { ticket_key: 'BAPI-123' })"),
|
|
2815
3570
|
skip_steps: z
|
|
@@ -2826,6 +3581,7 @@ registerTool("get_pipeline_recipe", {
|
|
|
2826
3581
|
"not via the variables map."),
|
|
2827
3582
|
},
|
|
2828
3583
|
}, async ({ pipeline: pipelineName, variables, skip_steps, auto_approve }) => {
|
|
3584
|
+
await ensureCustomPipelinesLoaded();
|
|
2829
3585
|
const pipelineDef = PIPELINES[pipelineName];
|
|
2830
3586
|
if (!pipelineDef) {
|
|
2831
3587
|
const available = Object.keys(PIPELINES).join(", ");
|
|
@@ -2854,12 +3610,17 @@ registerTool("get_pipeline_recipe", {
|
|
|
2854
3610
|
}
|
|
2855
3611
|
try {
|
|
2856
3612
|
const mergedVariables = {
|
|
2857
|
-
docs_dir:
|
|
3613
|
+
docs_dir: await getDocsDir(),
|
|
2858
3614
|
provider: "",
|
|
2859
3615
|
second_opinion: "",
|
|
2860
3616
|
auto_approve: auto_approve ? "true" : "",
|
|
2861
3617
|
...(variables ?? {}),
|
|
2862
3618
|
};
|
|
3619
|
+
// Auto-inject the stable idea-hash (like docs_dir) for the standalone
|
|
3620
|
+
// /idea-to-ticket path, mirroring runPipeline.
|
|
3621
|
+
if ("idea" in mergedVariables) {
|
|
3622
|
+
mergedVariables.idea_hash = deriveIdeaHash(mergedVariables.idea);
|
|
3623
|
+
}
|
|
2863
3624
|
const recipe = resolveRecipe(pipelineDef, INSTRUCTIONS, mergedVariables, skip_steps, !!auto_approve);
|
|
2864
3625
|
return {
|
|
2865
3626
|
content: [{
|
|
@@ -2892,27 +3653,41 @@ registerTool("get_pipeline_recipe", {
|
|
|
2892
3653
|
// REPO_MISMATCH | TOOL_ERROR). Pipeline state is persisted server-side; the
|
|
2893
3654
|
// idle TTL defaults to 24 hours and is auto-extended on every state
|
|
2894
3655
|
// transition.
|
|
2895
|
-
function
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
3656
|
+
async function buildPipelineOrchestratorDeps() {
|
|
3657
|
+
await ensureCustomPipelinesLoaded();
|
|
3658
|
+
return {
|
|
3659
|
+
baseUrl: BASE_URL,
|
|
3660
|
+
apiKey: await getResolvedApiKey(),
|
|
3661
|
+
repoName: REPO_NAME,
|
|
3662
|
+
docsDir: await getDocsDir(),
|
|
3663
|
+
pipelines: PIPELINES,
|
|
3664
|
+
instructions: INSTRUCTIONS,
|
|
3665
|
+
toolHandlers: TOOL_HANDLERS,
|
|
3666
|
+
};
|
|
2903
3667
|
}
|
|
2904
|
-
|
|
3668
|
+
// BAPI-326: dependency injection for the full-automation chain orchestrator.
|
|
3669
|
+
// Same shape as the pipeline deps plus the bundled chain-recipe registry. The
|
|
3670
|
+
// chain orchestrator never imports index.ts; everything flows through here.
|
|
3671
|
+
async function buildChainOrchestratorDeps() {
|
|
3672
|
+
await ensureCustomPipelinesLoaded();
|
|
2905
3673
|
return {
|
|
2906
3674
|
baseUrl: BASE_URL,
|
|
2907
|
-
apiKey:
|
|
3675
|
+
apiKey: await getResolvedApiKey(),
|
|
2908
3676
|
repoName: REPO_NAME,
|
|
2909
|
-
docsDir:
|
|
3677
|
+
docsDir: await getDocsDir(),
|
|
2910
3678
|
pipelines: PIPELINES,
|
|
3679
|
+
chainRecipes: CHAIN_RECIPES,
|
|
2911
3680
|
instructions: INSTRUCTIONS,
|
|
2912
3681
|
toolHandlers: TOOL_HANDLERS,
|
|
2913
3682
|
};
|
|
2914
3683
|
}
|
|
2915
3684
|
registerTool("run_pipeline", {
|
|
3685
|
+
annotations: {
|
|
3686
|
+
readOnlyHint: false,
|
|
3687
|
+
destructiveHint: false,
|
|
3688
|
+
idempotentHint: false,
|
|
3689
|
+
openWorldHint: true,
|
|
3690
|
+
},
|
|
2916
3691
|
description: "Execute a Bridge API pipeline by name. The orchestrator runs steps sequentially, " +
|
|
2917
3692
|
"dispatching mcp_call steps in-process and pausing on agent_task steps with a " +
|
|
2918
3693
|
"needs_agent_task envelope. Returns a unified envelope keyed on `status`: " +
|
|
@@ -2922,14 +3697,14 @@ registerTool("run_pipeline", {
|
|
|
2922
3697
|
"VALIDATION | NOT_FOUND | EXPIRED | REPO_MISMATCH | TOOL_ERROR). " +
|
|
2923
3698
|
"Paused runs auto-expire after an idle TTL (default 24 hours; override with " +
|
|
2924
3699
|
"`ttl_seconds`). The TTL is reset on every state transition.\n\n" +
|
|
2925
|
-
"
|
|
2926
|
-
|
|
3700
|
+
"Call list_pipelines for the current resolved catalog of available pipeline " +
|
|
3701
|
+
"names (bundled plus any custom user pipelines).",
|
|
2927
3702
|
inputSchema: {
|
|
2928
3703
|
pipeline: z
|
|
2929
3704
|
.string()
|
|
2930
3705
|
.describe("Pipeline name (e.g. 'review-ticket', 'implement-ticket')"),
|
|
2931
3706
|
variables: z
|
|
2932
|
-
.record(z.string())
|
|
3707
|
+
.record(z.string(), z.string())
|
|
2933
3708
|
.optional()
|
|
2934
3709
|
.describe("Key-value pairs for variable substitution (e.g. { ticket_key: 'BAPI-123' }). " +
|
|
2935
3710
|
"Do NOT pass `auto_approve` here — use the top-level parameter."),
|
|
@@ -2948,7 +3723,7 @@ registerTool("run_pipeline", {
|
|
|
2948
3723
|
.describe("Override the default 24-hour idle TTL for this run. Must be a positive integer."),
|
|
2949
3724
|
},
|
|
2950
3725
|
}, async (input) => {
|
|
2951
|
-
const result = await runPipeline(buildPipelineOrchestratorDeps(), input);
|
|
3726
|
+
const result = await runPipeline(await buildPipelineOrchestratorDeps(), input);
|
|
2952
3727
|
return {
|
|
2953
3728
|
content: [
|
|
2954
3729
|
{ type: "text", text: JSON.stringify(result, null, 2) },
|
|
@@ -2956,6 +3731,12 @@ registerTool("run_pipeline", {
|
|
|
2956
3731
|
};
|
|
2957
3732
|
});
|
|
2958
3733
|
registerTool("resume_pipeline", {
|
|
3734
|
+
annotations: {
|
|
3735
|
+
readOnlyHint: false,
|
|
3736
|
+
destructiveHint: false,
|
|
3737
|
+
idempotentHint: false,
|
|
3738
|
+
openWorldHint: true,
|
|
3739
|
+
},
|
|
2959
3740
|
description: "Resume a paused pipeline run with the result of the agent_task. Provide the " +
|
|
2960
3741
|
"`pipeline_run_id` returned by the prior needs_agent_task envelope, and the string " +
|
|
2961
3742
|
"the instruction's `## Return` section asked you to produce as `agent_result`. " +
|
|
@@ -2971,7 +3752,7 @@ registerTool("resume_pipeline", {
|
|
|
2971
3752
|
.describe("The string the paused instruction's ## Return section asked you to produce"),
|
|
2972
3753
|
},
|
|
2973
3754
|
}, async (input) => {
|
|
2974
|
-
const result = await resumePipeline(buildPipelineOrchestratorDeps(), input);
|
|
3755
|
+
const result = await resumePipeline(await buildPipelineOrchestratorDeps(), input);
|
|
2975
3756
|
return {
|
|
2976
3757
|
content: [
|
|
2977
3758
|
{ type: "text", text: JSON.stringify(result, null, 2) },
|
|
@@ -2979,6 +3760,12 @@ registerTool("resume_pipeline", {
|
|
|
2979
3760
|
};
|
|
2980
3761
|
});
|
|
2981
3762
|
registerTool("list_pipeline_runs", {
|
|
3763
|
+
annotations: {
|
|
3764
|
+
readOnlyHint: true,
|
|
3765
|
+
destructiveHint: false,
|
|
3766
|
+
idempotentHint: true,
|
|
3767
|
+
openWorldHint: true,
|
|
3768
|
+
},
|
|
2982
3769
|
description: "List recent pipeline runs for the configured repository, newest first. Returns " +
|
|
2983
3770
|
"metadata only — `resolved_recipe`, resolved params, instruction text, results, " +
|
|
2984
3771
|
"and agent outputs are intentionally excluded. Use this to recover a " +
|
|
@@ -2992,7 +3779,7 @@ registerTool("list_pipeline_runs", {
|
|
|
2992
3779
|
.describe("Optional status filter"),
|
|
2993
3780
|
},
|
|
2994
3781
|
}, async (input) => {
|
|
2995
|
-
const result = await listPipelineRuns(buildPipelineOrchestratorDeps(), input);
|
|
3782
|
+
const result = await listPipelineRuns(await buildPipelineOrchestratorDeps(), input);
|
|
2996
3783
|
return {
|
|
2997
3784
|
content: [
|
|
2998
3785
|
{ type: "text", text: JSON.stringify(result, null, 2) },
|
|
@@ -3000,6 +3787,12 @@ registerTool("list_pipeline_runs", {
|
|
|
3000
3787
|
};
|
|
3001
3788
|
});
|
|
3002
3789
|
registerTool("delete_pipeline_run", {
|
|
3790
|
+
annotations: {
|
|
3791
|
+
readOnlyHint: false,
|
|
3792
|
+
destructiveHint: true,
|
|
3793
|
+
idempotentHint: true,
|
|
3794
|
+
openWorldHint: true,
|
|
3795
|
+
},
|
|
3003
3796
|
description: "Delete a pipeline run row (any status). Use this to discard orphaned `running` " +
|
|
3004
3797
|
"rows from a previous session that can't be resumed (resume_pipeline only accepts " +
|
|
3005
3798
|
"`paused`), to clean up after a failed run, or to remove a no-longer-needed paused " +
|
|
@@ -3013,7 +3806,98 @@ registerTool("delete_pipeline_run", {
|
|
|
3013
3806
|
.describe("UUID of the pipeline run to delete."),
|
|
3014
3807
|
},
|
|
3015
3808
|
}, async (input) => {
|
|
3016
|
-
const result = await deletePipelineRun(buildPipelineOrchestratorDeps(), input);
|
|
3809
|
+
const result = await deletePipelineRun(await buildPipelineOrchestratorDeps(), input);
|
|
3810
|
+
return {
|
|
3811
|
+
content: [
|
|
3812
|
+
{ type: "text", text: JSON.stringify(result, null, 2) },
|
|
3813
|
+
],
|
|
3814
|
+
};
|
|
3815
|
+
});
|
|
3816
|
+
// ---------------------------------------------------------------------------
|
|
3817
|
+
// Full Automation Chain Tools (BAPI-326)
|
|
3818
|
+
// ---------------------------------------------------------------------------
|
|
3819
|
+
//
|
|
3820
|
+
// run_full_automation / resume_full_automation drive an idea through three
|
|
3821
|
+
// chained stages (idea-to-ticket -> review-ticket fan-out -> start-tickets CLI
|
|
3822
|
+
// seam) via the existing pipeline runner. They share the chain envelope shape
|
|
3823
|
+
// (status: needs_agent_task | completed | failed) with a strict error_code
|
|
3824
|
+
// enum (VALIDATION | NOT_FOUND | EXPIRED | REPO_MISMATCH | TOOL_ERROR). Chain
|
|
3825
|
+
// state is persisted server-side via /jira/chain-runs/runs. Registered AFTER
|
|
3826
|
+
// TOOL_HANDLERS and the pipeline tools so the orchestrator can dispatch the
|
|
3827
|
+
// existing child pipelines in-process.
|
|
3828
|
+
registerTool("run_full_automation", {
|
|
3829
|
+
annotations: {
|
|
3830
|
+
readOnlyHint: false,
|
|
3831
|
+
destructiveHint: false,
|
|
3832
|
+
idempotentHint: false,
|
|
3833
|
+
openWorldHint: true,
|
|
3834
|
+
},
|
|
3835
|
+
description: "Run the full-automation chain for an idea: create ticket(s) " +
|
|
3836
|
+
"(idea-to-ticket), review each created ticket (review-ticket fan-out), " +
|
|
3837
|
+
"then emit the exact `/start-tickets ...` command for you to invoke in " +
|
|
3838
|
+
"this same session. Returns the chain envelope keyed on `status`: " +
|
|
3839
|
+
"`needs_agent_task` (perform the `next_action.instruction`, then call " +
|
|
3840
|
+
"`resume_full_automation` with the result as `agent_result`), `completed`, " +
|
|
3841
|
+
"or `failed` (check `error_code`: VALIDATION | NOT_FOUND | EXPIRED | " +
|
|
3842
|
+
"REPO_MISMATCH | TOOL_ERROR). Provide the idea via `idea` or `idea_file` " +
|
|
3843
|
+
"(mutually exclusive).",
|
|
3844
|
+
inputSchema: {
|
|
3845
|
+
idea: z.string().optional(),
|
|
3846
|
+
idea_file: z.string().optional(),
|
|
3847
|
+
auto_approve: z
|
|
3848
|
+
.union([z.boolean(), z.literal("true"), z.literal("false")])
|
|
3849
|
+
.optional(),
|
|
3850
|
+
scheduled_at: z.string().optional(),
|
|
3851
|
+
max_children: z.number().int().positive().optional(),
|
|
3852
|
+
allow_duplicate: z.boolean().optional(),
|
|
3853
|
+
agent: z.enum(["claude"]).optional(),
|
|
3854
|
+
ttl_seconds: z.number().int().positive().optional(),
|
|
3855
|
+
},
|
|
3856
|
+
}, async (input) => {
|
|
3857
|
+
const { idea, idea_file, ...rest } = input;
|
|
3858
|
+
if (idea !== undefined && idea_file !== undefined) {
|
|
3859
|
+
return {
|
|
3860
|
+
content: [{
|
|
3861
|
+
type: "text",
|
|
3862
|
+
text: JSON.stringify({
|
|
3863
|
+
error: "BAD_REQUEST",
|
|
3864
|
+
status: 400,
|
|
3865
|
+
message: "Provide exactly one of `idea` or `idea_file`, not both.",
|
|
3866
|
+
}),
|
|
3867
|
+
}],
|
|
3868
|
+
};
|
|
3869
|
+
}
|
|
3870
|
+
const resolved = await resolveTextOrFile(idea, idea_file, "idea");
|
|
3871
|
+
if (!resolved.ok) {
|
|
3872
|
+
return resolved.errorResponse;
|
|
3873
|
+
}
|
|
3874
|
+
const result = await runFullAutomation(await buildChainOrchestratorDeps(), {
|
|
3875
|
+
idea: resolved.text,
|
|
3876
|
+
...rest,
|
|
3877
|
+
});
|
|
3878
|
+
return {
|
|
3879
|
+
content: [
|
|
3880
|
+
{ type: "text", text: JSON.stringify(result, null, 2) },
|
|
3881
|
+
],
|
|
3882
|
+
};
|
|
3883
|
+
});
|
|
3884
|
+
registerTool("resume_full_automation", {
|
|
3885
|
+
annotations: {
|
|
3886
|
+
readOnlyHint: false,
|
|
3887
|
+
destructiveHint: false,
|
|
3888
|
+
idempotentHint: false,
|
|
3889
|
+
openWorldHint: true,
|
|
3890
|
+
},
|
|
3891
|
+
description: "Resume a paused full-automation chain run. Provide the `chain_run_id` " +
|
|
3892
|
+
"returned by the prior needs_agent_task envelope and the string the " +
|
|
3893
|
+
"instruction asked you to produce as `agent_result`. Returns the same " +
|
|
3894
|
+
"chain envelope shape as `run_full_automation`.",
|
|
3895
|
+
inputSchema: {
|
|
3896
|
+
chain_run_id: z.string(),
|
|
3897
|
+
agent_result: z.string(),
|
|
3898
|
+
},
|
|
3899
|
+
}, async (input) => {
|
|
3900
|
+
const result = await resumeFullAutomation(await buildChainOrchestratorDeps(), input);
|
|
3017
3901
|
return {
|
|
3018
3902
|
content: [
|
|
3019
3903
|
{ type: "text", text: JSON.stringify(result, null, 2) },
|
|
@@ -3023,12 +3907,125 @@ registerTool("delete_pipeline_run", {
|
|
|
3023
3907
|
// ---------------------------------------------------------------------------
|
|
3024
3908
|
// generate_decision_page
|
|
3025
3909
|
// ---------------------------------------------------------------------------
|
|
3910
|
+
/**
|
|
3911
|
+
* Detect URL-encoded path tokens that could smuggle traversal/separators past
|
|
3912
|
+
* literal-character checks: `%2e` (dot), `%2f` (slash), and `%5c` (backslash),
|
|
3913
|
+
* all case-insensitive. Defense-in-depth alongside the literal-character rules.
|
|
3914
|
+
*/
|
|
3915
|
+
export function containsUnsafeEncodedPathToken(value) {
|
|
3916
|
+
return /%2e/i.test(value) || /%2f/i.test(value) || /%5c/i.test(value);
|
|
3917
|
+
}
|
|
3918
|
+
/**
|
|
3919
|
+
* True when `value` is absolute on POSIX OR Windows semantics, regardless of the
|
|
3920
|
+
* host OS. Node's `path.isAbsolute` is platform-specific, so a Windows drive
|
|
3921
|
+
* (`C:\`) or UNC (`\\server\share`) path would not be caught on a POSIX host
|
|
3922
|
+
* without checking `path.win32.isAbsolute` explicitly.
|
|
3923
|
+
*/
|
|
3924
|
+
export function isPlatformAbsolutePath(value) {
|
|
3925
|
+
return (path.posix.isAbsolute(value) ||
|
|
3926
|
+
path.win32.isAbsolute(value) ||
|
|
3927
|
+
path.isAbsolute(value));
|
|
3928
|
+
}
|
|
3929
|
+
/**
|
|
3930
|
+
* Validate the docs-relative `output_subdir`. Returns a user-facing error
|
|
3931
|
+
* message string when invalid, or `null` when valid. Never sanitizes/auto-fixes.
|
|
3932
|
+
*/
|
|
3933
|
+
export function validateDecisionPageOutputSubdir(value) {
|
|
3934
|
+
if (value.trim().length === 0) {
|
|
3935
|
+
return `Invalid output_subdir: must not be empty or whitespace-only.`;
|
|
3936
|
+
}
|
|
3937
|
+
if (value.includes("\0")) {
|
|
3938
|
+
return `Invalid output_subdir: must not contain null bytes.`;
|
|
3939
|
+
}
|
|
3940
|
+
if (containsUnsafeEncodedPathToken(value)) {
|
|
3941
|
+
return `Invalid output_subdir "${value}": must not contain encoded path tokens (%2e, %2f, %5c).`;
|
|
3942
|
+
}
|
|
3943
|
+
if (isPlatformAbsolutePath(value)) {
|
|
3944
|
+
return `Invalid output_subdir "${value}": must be a relative path, not an absolute path.`;
|
|
3945
|
+
}
|
|
3946
|
+
if (value.includes("\\")) {
|
|
3947
|
+
return `Invalid output_subdir "${value}": backslashes are not allowed; use "/" to separate nested directories.`;
|
|
3948
|
+
}
|
|
3949
|
+
// Split on both separators so a "\\"-bearing value (already rejected above) and
|
|
3950
|
+
// "/"-separated nesting are both checked segment-by-segment for "..".
|
|
3951
|
+
const segments = value.split(/[/\\]/);
|
|
3952
|
+
if (segments.some((segment) => segment === "..")) {
|
|
3953
|
+
return `Invalid output_subdir "${value}": must not contain ".." path segments.`;
|
|
3954
|
+
}
|
|
3955
|
+
return null;
|
|
3956
|
+
}
|
|
3957
|
+
/**
|
|
3958
|
+
* Validate the `output_filename`. Returns a user-facing error message string
|
|
3959
|
+
* when invalid, or `null` when valid. Requires a literal `.html` suffix and
|
|
3960
|
+
* never auto-appends it.
|
|
3961
|
+
*/
|
|
3962
|
+
export function validateDecisionPageOutputFilename(value) {
|
|
3963
|
+
if (value.trim().length === 0) {
|
|
3964
|
+
return `Invalid output_filename: must not be empty or whitespace-only.`;
|
|
3965
|
+
}
|
|
3966
|
+
if (value.includes("\0")) {
|
|
3967
|
+
return `Invalid output_filename: must not contain null bytes.`;
|
|
3968
|
+
}
|
|
3969
|
+
if (containsUnsafeEncodedPathToken(value)) {
|
|
3970
|
+
return `Invalid output_filename "${value}": must not contain encoded path tokens (%2e, %2f, %5c).`;
|
|
3971
|
+
}
|
|
3972
|
+
if (value.includes("/") || value.includes("\\")) {
|
|
3973
|
+
return `Invalid output_filename "${value}": must not contain path separators.`;
|
|
3974
|
+
}
|
|
3975
|
+
if (value === "." || value === "..") {
|
|
3976
|
+
return `Invalid output_filename "${value}": must be a real filename, not "." or "..".`;
|
|
3977
|
+
}
|
|
3978
|
+
if (!value.endsWith(".html")) {
|
|
3979
|
+
return `Invalid output_filename "${value}": must end with the ".html" suffix.`;
|
|
3980
|
+
}
|
|
3981
|
+
return null;
|
|
3982
|
+
}
|
|
3983
|
+
/**
|
|
3984
|
+
* Validate and resolve the decision-page output target under the docs base.
|
|
3985
|
+
* Runs string validators first (so an invalid subdir short-circuits before any
|
|
3986
|
+
* docs-dir resolution), then resolves an absolute target and enforces a
|
|
3987
|
+
* containment backstop that the target stays strictly under the docs base.
|
|
3988
|
+
*/
|
|
3989
|
+
export async function resolveDecisionPageOutputTarget(outputSubdir, outputFilename) {
|
|
3990
|
+
const subdirError = validateDecisionPageOutputSubdir(outputSubdir);
|
|
3991
|
+
if (subdirError)
|
|
3992
|
+
return { ok: false, message: subdirError };
|
|
3993
|
+
const filenameError = validateDecisionPageOutputFilename(outputFilename);
|
|
3994
|
+
if (filenameError)
|
|
3995
|
+
return { ok: false, message: filenameError };
|
|
3996
|
+
// getDocsDir() is async — must be awaited before path.resolve, otherwise the
|
|
3997
|
+
// Promise would be string-coerced into a bogus path.
|
|
3998
|
+
const docsBase = path.resolve(await getDocsDir());
|
|
3999
|
+
const resolvedTarget = path.resolve(docsBase, outputSubdir, outputFilename);
|
|
4000
|
+
// Containment backstop using normalized resolved paths. The strict
|
|
4001
|
+
// `docsBase + path.sep` boundary prevents a sibling-prefix bypass such as
|
|
4002
|
+
// "/tmp/docs-evil" passing a naive startsWith("/tmp/docs") check.
|
|
4003
|
+
if (!resolvedTarget.startsWith(docsBase + path.sep)) {
|
|
4004
|
+
return {
|
|
4005
|
+
ok: false,
|
|
4006
|
+
message: `Invalid output target: the resolved output path must stay under the docs directory.`,
|
|
4007
|
+
};
|
|
4008
|
+
}
|
|
4009
|
+
return {
|
|
4010
|
+
ok: true,
|
|
4011
|
+
docsPath: path.dirname(resolvedTarget),
|
|
4012
|
+
filePath: resolvedTarget,
|
|
4013
|
+
};
|
|
4014
|
+
}
|
|
3026
4015
|
registerTool("generate_decision_page", {
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
4016
|
+
annotations: {
|
|
4017
|
+
readOnlyHint: false,
|
|
4018
|
+
destructiveHint: false,
|
|
4019
|
+
idempotentHint: true,
|
|
4020
|
+
openWorldHint: true,
|
|
4021
|
+
},
|
|
4022
|
+
description: "Generate a local, reusable, review-shaped HTML decision page for capturing user decisions. " +
|
|
4023
|
+
"Renders recommendation-driven decision cards with per-option consequence lines, optional " +
|
|
4024
|
+
"original-question and closed-by-default codebase-evidence display, and an optional confirmed-" +
|
|
4025
|
+
"improvements list. Presentation labels (title, intro, section/improvements headings) are " +
|
|
4026
|
+
"overridable, and the output location under the docs directory is configurable, so automations " +
|
|
4027
|
+
"beyond ticket review can reuse it. The user opens the HTML file in a browser, makes selections, " +
|
|
4028
|
+
"and copies the resulting JSON output back to the agent.",
|
|
3032
4029
|
inputSchema: DecisionPageInputShape,
|
|
3033
4030
|
}, async (input) => {
|
|
3034
4031
|
// Returned messages travel through JSON.stringify in the MCP envelope below,
|
|
@@ -3078,9 +4075,19 @@ registerTool("generate_decision_page", {
|
|
|
3078
4075
|
}
|
|
3079
4076
|
seenCiIds.add(ci.id);
|
|
3080
4077
|
}
|
|
4078
|
+
// Resolve the (optionally caller-overridden) output location. Defaults
|
|
4079
|
+
// reproduce the legacy review path: {docs_dir}/review/{ticket_key}-decisions.html.
|
|
4080
|
+
// Runs after semantic item validation and before any asset read or file write.
|
|
4081
|
+
const outputSubdir = input.output_subdir ?? "review";
|
|
4082
|
+
const outputFilename = input.output_filename ?? `${input.ticket_key}-decisions.html`;
|
|
4083
|
+
const outputTarget = await resolveDecisionPageOutputTarget(outputSubdir, outputFilename);
|
|
4084
|
+
if (!outputTarget.ok) {
|
|
4085
|
+
return validationError(outputTarget.message);
|
|
4086
|
+
}
|
|
3081
4087
|
// Read design assets and base64-encode for embedding
|
|
3082
|
-
const
|
|
3083
|
-
const
|
|
4088
|
+
const projectRootForAssets = await getProjectRoot();
|
|
4089
|
+
const assetsDir = path.join(projectRootForAssets, "design-assets");
|
|
4090
|
+
const fontsDir = path.join(projectRootForAssets, "public", "fonts");
|
|
3084
4091
|
let faviconBase64 = "";
|
|
3085
4092
|
let logoBase64 = "";
|
|
3086
4093
|
try {
|
|
@@ -3093,15 +4100,16 @@ registerTool("generate_decision_page", {
|
|
|
3093
4100
|
logoBase64 = logoBuf.toString("base64");
|
|
3094
4101
|
}
|
|
3095
4102
|
catch { /* logo optional */ }
|
|
3096
|
-
//
|
|
3097
|
-
|
|
4103
|
+
// Use the validated output target. fontsRelPath stays relative to the actual
|
|
4104
|
+
// output directory so font URLs resolve regardless of a custom subdir.
|
|
4105
|
+
const docsPath = outputTarget.docsPath;
|
|
4106
|
+
const filePath = outputTarget.filePath;
|
|
3098
4107
|
const fontsRelPath = path.relative(docsPath, fontsDir);
|
|
3099
4108
|
const html = generateDecisionPageHtml(input, {
|
|
3100
4109
|
faviconBase64,
|
|
3101
4110
|
logoBase64,
|
|
3102
4111
|
fontsRelPath,
|
|
3103
4112
|
});
|
|
3104
|
-
const filePath = path.join(docsPath, `${input.ticket_key}-decisions.html`);
|
|
3105
4113
|
await mkdir(docsPath, { recursive: true });
|
|
3106
4114
|
await writeFile(filePath, html, "utf-8");
|
|
3107
4115
|
return {
|
|
@@ -3117,10 +4125,79 @@ registerTool("generate_decision_page", {
|
|
|
3117
4125
|
};
|
|
3118
4126
|
});
|
|
3119
4127
|
// ---------------------------------------------------------------------------
|
|
4128
|
+
// Tiered section-executor telemetry (BAPI-346, Ticket 2)
|
|
4129
|
+
// ---------------------------------------------------------------------------
|
|
4130
|
+
/**
|
|
4131
|
+
* POST one per-section execution-telemetry row through the Bridge API HTTP
|
|
4132
|
+
* boundary (`/jira/tiered-section-metrics`). Keeps the MCP layer free of any
|
|
4133
|
+
* Python DAL import — the server route owns the dormant BAPI-345 DAL seam.
|
|
4134
|
+
*/
|
|
4135
|
+
async function recordTieredSectionMetric(args) {
|
|
4136
|
+
const payload = {
|
|
4137
|
+
repo_name: REPO_NAME,
|
|
4138
|
+
ticket_number: args.ticket_number,
|
|
4139
|
+
section_id: args.section_id,
|
|
4140
|
+
tier_assigned: args.tier_assigned,
|
|
4141
|
+
mode_run: args.mode_run,
|
|
4142
|
+
metrics: args.metrics ?? {},
|
|
4143
|
+
};
|
|
4144
|
+
const resp = await fetch(buildUrl("/tiered-section-metrics"), {
|
|
4145
|
+
method: "POST",
|
|
4146
|
+
headers: await getPostHeaders(),
|
|
4147
|
+
body: JSON.stringify(payload),
|
|
4148
|
+
});
|
|
4149
|
+
const text = await handleResponse(resp);
|
|
4150
|
+
return { content: [{ type: "text", text }] };
|
|
4151
|
+
}
|
|
4152
|
+
registerTool("record_tiered_section_metric", {
|
|
4153
|
+
annotations: {
|
|
4154
|
+
readOnlyHint: false,
|
|
4155
|
+
destructiveHint: false,
|
|
4156
|
+
idempotentHint: false,
|
|
4157
|
+
openWorldHint: true,
|
|
4158
|
+
},
|
|
4159
|
+
description: "Record one per-section execution-telemetry row for the Claude Code tiered " +
|
|
4160
|
+
"section executor (BAPI-346). This writes server-side telemetry state only — " +
|
|
4161
|
+
"it does NOT mutate Jira, code, or any ticket. It is intended to be called by " +
|
|
4162
|
+
"the tiered section executor after each section attempt; the executor Warns and " +
|
|
4163
|
+
"continues if recording fails, so telemetry never blocks an implementation. " +
|
|
4164
|
+
"Typed columns are ticket_number, section_id, tier_assigned, and mode_run; put " +
|
|
4165
|
+
"any additional per-section detail in the flexible `metrics` object.",
|
|
4166
|
+
inputSchema: {
|
|
4167
|
+
ticket_number: z
|
|
4168
|
+
.string()
|
|
4169
|
+
.describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123, BAPI-42)"),
|
|
4170
|
+
section_id: z
|
|
4171
|
+
.string()
|
|
4172
|
+
.describe("The BAPI-345 section graph `id` of the section this metric is for."),
|
|
4173
|
+
tier_assigned: z
|
|
4174
|
+
.enum(["cheap", "basic", "premium"])
|
|
4175
|
+
.describe("The tier resolved for the section (cheap→haiku, basic→sonnet, premium→opus)."),
|
|
4176
|
+
mode_run: z
|
|
4177
|
+
.enum(["sub_agent", "inline_tiered", "inline_default"])
|
|
4178
|
+
.describe("The execution mode the section actually ran in."),
|
|
4179
|
+
metrics: z
|
|
4180
|
+
.record(z.string(), z.unknown())
|
|
4181
|
+
.optional()
|
|
4182
|
+
.describe("Optional flexible JSON detail (e.g. contract_version, mode_intended, " +
|
|
4183
|
+
"model_resolved, tokens, cache, verification, escalation_count, " +
|
|
4184
|
+
"budget_snapshot, files_changed, handoff, degraded_reason)."),
|
|
4185
|
+
},
|
|
4186
|
+
}, async ({ ticket_number, section_id, tier_assigned, mode_run, metrics }) => recordTieredSectionMetric({
|
|
4187
|
+
ticket_number,
|
|
4188
|
+
section_id,
|
|
4189
|
+
tier_assigned,
|
|
4190
|
+
mode_run,
|
|
4191
|
+
metrics,
|
|
4192
|
+
}));
|
|
4193
|
+
// ---------------------------------------------------------------------------
|
|
3120
4194
|
// Entry point
|
|
3121
4195
|
// ---------------------------------------------------------------------------
|
|
3122
4196
|
const transport = new StdioServerTransport();
|
|
3123
4197
|
await server.connect(transport);
|
|
4198
|
+
// Record that the server has connected so project-root resolution may now query
|
|
4199
|
+
// MCP `roots/list` (it falls through to CLAUDE_PROJECT_DIR / cwd before this).
|
|
4200
|
+
serverConnected = true;
|
|
3124
4201
|
console.error("Bridge API MCP server running on stdio");
|
|
3125
4202
|
// Fire-and-forget update check — delay to let MCP client attach listeners
|
|
3126
4203
|
(async () => {
|