@bridge_gpt/mcp-server 0.1.17 → 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 -197
- 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 +4 -4
- 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 +247 -33
- package/build/commands.generated.js +5 -5
- 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/git-ignore-utils.js +63 -0
- package/build/index.js +1510 -560
- 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 +9 -1
- package/build/pipeline-utils.js +33 -0
- package/build/pipelines.generated.js +36 -5
- package/build/schedule-run.js +6 -6
- package/build/start-tickets-prereqs.js +90 -1
- package/build/start-tickets.js +106 -14
- package/build/third-party-mcp-targets.js +75 -0
- package/build/version.generated.js +1 -1
- package/package.json +3 -3
- package/pipelines/full-automation.json +3 -1
- package/pipelines/implement-ticket.json +28 -2
- package/smoke-test/SMOKE-TEST.md +4 -2
package/build/index.js
CHANGED
|
@@ -14,6 +14,8 @@ 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
21
|
import { PIPELINES as BUNDLED_PIPELINES, INSTRUCTIONS as BUNDLED_INSTRUCTIONS, CHAIN_RECIPES } from "./pipelines.generated.js";
|
|
@@ -26,8 +28,14 @@ import { resolveRecipe, loadCustomPipelines } from "./pipeline-utils.js";
|
|
|
26
28
|
import { runStartTicketsCli } from "./start-tickets.js";
|
|
27
29
|
import { runDoctorCli } from "./doctor.js";
|
|
28
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";
|
|
29
36
|
import { generateDecisionPageHtml } from "./decision-page-template.js";
|
|
30
37
|
import { DecisionPageInputShape } from "./decision-page-schema.js";
|
|
38
|
+
import { slugify, saveBrainstormResultsToDir, } from "./brainstorm-files.js";
|
|
31
39
|
import { runPipeline, resumePipeline, listPipelineRuns, deletePipelineRun, deriveIdeaHash, } from "./pipeline-orchestrator.js";
|
|
32
40
|
import { runFullAutomation, resumeFullAutomation, } from "./chain-orchestrator.js";
|
|
33
41
|
// Mutable pipeline/instruction state — starts with bundled, merged with user at startup
|
|
@@ -39,15 +47,130 @@ let userPipelineKeys = new Set();
|
|
|
39
47
|
// ---------------------------------------------------------------------------
|
|
40
48
|
const BASE_URL = process.env.BAPI_BASE_URL ?? "https://bridgegpt-api.com";
|
|
41
49
|
const REPO_NAME = process.env.BAPI_REPO_NAME ?? "";
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
+
}
|
|
51
174
|
// ---------------------------------------------------------------------------
|
|
52
175
|
// Helpers
|
|
53
176
|
// ---------------------------------------------------------------------------
|
|
@@ -64,18 +187,32 @@ function buildGetUrl(path, params) {
|
|
|
64
187
|
}
|
|
65
188
|
return url.toString();
|
|
66
189
|
}
|
|
67
|
-
function getDocsPath(subdir) {
|
|
68
|
-
return path.join(
|
|
190
|
+
async function getDocsPath(subdir) {
|
|
191
|
+
return path.join(await getDocsDir(), subdir);
|
|
69
192
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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;
|
|
79
216
|
}
|
|
80
217
|
const ERROR_CODES = {
|
|
81
218
|
400: "BAD_REQUEST",
|
|
@@ -104,6 +241,29 @@ async function handleResponse(resp) {
|
|
|
104
241
|
let message = rawText;
|
|
105
242
|
try {
|
|
106
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
|
+
}
|
|
107
267
|
if (parsed.detail) {
|
|
108
268
|
message = typeof parsed.detail === "string" ? parsed.detail : JSON.stringify(parsed.detail);
|
|
109
269
|
}
|
|
@@ -133,7 +293,7 @@ async function createTicketRequest(params) {
|
|
|
133
293
|
payload.parent_key = params.parent_key;
|
|
134
294
|
const resp = await fetch(buildUrl("/create-ticket"), {
|
|
135
295
|
method: "POST",
|
|
136
|
-
headers:
|
|
296
|
+
headers: await getPostHeaders(),
|
|
137
297
|
body: JSON.stringify(payload),
|
|
138
298
|
});
|
|
139
299
|
return handleResponse(resp);
|
|
@@ -149,6 +309,100 @@ async function saveLocally(dir, filename, content) {
|
|
|
149
309
|
return `\n\n---\nNote: Failed to save file to ${filePath}: ${writeErr}`;
|
|
150
310
|
}
|
|
151
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
|
+
}
|
|
152
406
|
async function resolveTextOrFile(textValue, filePath, textLabel) {
|
|
153
407
|
if (!filePath && !textValue) {
|
|
154
408
|
return {
|
|
@@ -209,7 +463,7 @@ async function pollForResult(getUrl, timeoutMs, label) {
|
|
|
209
463
|
};
|
|
210
464
|
}
|
|
211
465
|
console.error(`${label} in progress... (elapsed: ${elapsed}s)`);
|
|
212
|
-
const resp = await fetch(getUrl, { headers:
|
|
466
|
+
const resp = await fetch(getUrl, { headers: await getGetHeaders() });
|
|
213
467
|
if (resp.status === 404 || resp.status === 202) {
|
|
214
468
|
await resp.text();
|
|
215
469
|
}
|
|
@@ -223,36 +477,311 @@ async function pollForResult(getUrl, timeoutMs, label) {
|
|
|
223
477
|
}
|
|
224
478
|
}
|
|
225
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
|
+
}
|
|
226
726
|
// ---------------------------------------------------------------------------
|
|
227
727
|
// CLI: --init scaffolds slash commands and MCP configs into the current project
|
|
228
728
|
// ---------------------------------------------------------------------------
|
|
229
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.
|
|
230
733
|
return {
|
|
231
734
|
command: "npx",
|
|
232
735
|
args: ["-y", "@bridge_gpt/mcp-server"],
|
|
233
736
|
env: {
|
|
234
737
|
BAPI_BASE_URL: "https://bridgegpt-api.com",
|
|
235
738
|
BAPI_REPO_NAME: "YOUR_REPO_NAME",
|
|
236
|
-
BAPI_API_KEY: "YOUR_API_KEY",
|
|
237
739
|
BAPI_DOCS_DIR: "docs/tmp",
|
|
238
740
|
BAPI_PROJECT_ROOT: cwd,
|
|
239
741
|
},
|
|
240
742
|
};
|
|
241
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
|
+
}
|
|
242
779
|
async function ensureGitignored(cwd, filePath) {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
catch { /* .gitignore doesn't exist yet */ }
|
|
250
|
-
// Check if already present (exact line match)
|
|
251
|
-
const lines = content.split("\n");
|
|
252
|
-
if (lines.some((line) => line.trim() === entry))
|
|
253
|
-
return;
|
|
254
|
-
const separator = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
|
|
255
|
-
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
|
+
});
|
|
256
785
|
}
|
|
257
786
|
/**
|
|
258
787
|
* Core initialization logic shared by --init and --upgrade.
|
|
@@ -609,10 +1138,34 @@ body explicitly says to serialize structured output.
|
|
|
609
1138
|
console.log(` ${path.relative(cwd, examplePath)} (written)`);
|
|
610
1139
|
}
|
|
611
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).");
|
|
612
1163
|
// ---- Phase 7: Final summary ----
|
|
613
1164
|
if (anyCreatedOrAdded) {
|
|
614
|
-
console.log("\
|
|
615
|
-
"
|
|
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");
|
|
616
1169
|
}
|
|
617
1170
|
}
|
|
618
1171
|
// ---------------------------------------------------------------------------
|
|
@@ -703,6 +1256,14 @@ async function dispatchCliSubcommand(argv) {
|
|
|
703
1256
|
if (argv[0] === "start-tickets") {
|
|
704
1257
|
return runStartTicketsCli(argv.slice(1));
|
|
705
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
|
+
}
|
|
706
1267
|
// The read-only `doctor` subcommand is routed beside start-tickets, before the
|
|
707
1268
|
// flag guards and well before MCP server construction (it never starts the server).
|
|
708
1269
|
if (argv[0] === "doctor") {
|
|
@@ -714,6 +1275,12 @@ async function dispatchCliSubcommand(argv) {
|
|
|
714
1275
|
if (argv[0] === "schedule-run") {
|
|
715
1276
|
return runScheduleRunCli(argv.slice(1));
|
|
716
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
|
+
}
|
|
717
1284
|
// --init takes precedence over --upgrade; both are position-independent flags.
|
|
718
1285
|
if (argv.includes("--init")) {
|
|
719
1286
|
return runInitCli(cwd);
|
|
@@ -802,7 +1369,19 @@ const registerTool = ((name, config, handler) => {
|
|
|
802
1369
|
// ---------------------------------------------------------------------------
|
|
803
1370
|
// Tools
|
|
804
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.
|
|
805
1378
|
registerTool("ping", {
|
|
1379
|
+
annotations: {
|
|
1380
|
+
readOnlyHint: true,
|
|
1381
|
+
destructiveHint: false,
|
|
1382
|
+
idempotentHint: true,
|
|
1383
|
+
openWorldHint: true,
|
|
1384
|
+
},
|
|
806
1385
|
description: "Test connectivity to Bridge API. Validates that the API key is accepted and the configured repository is accessible. " +
|
|
807
1386
|
"Returns JSON with {status: 'ok', repo_name: '<configured repo>'}. " +
|
|
808
1387
|
"Use this as a quick health check before other operations, or to verify your Bridge API configuration is working. " +
|
|
@@ -811,13 +1390,21 @@ registerTool("ping", {
|
|
|
811
1390
|
inputSchema: {},
|
|
812
1391
|
}, async () => {
|
|
813
1392
|
const url = buildGetUrl("/ping", { repo_name: REPO_NAME });
|
|
814
|
-
const resp = await fetch(url, { headers:
|
|
1393
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
815
1394
|
const text = await handleResponse(resp);
|
|
816
1395
|
return { content: [{ type: "text", text }] };
|
|
817
1396
|
});
|
|
818
1397
|
registerTool("second_opinion", {
|
|
819
|
-
|
|
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. " +
|
|
820
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. " +
|
|
821
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'). " +
|
|
822
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. " +
|
|
823
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. " +
|
|
@@ -839,7 +1426,7 @@ registerTool("second_opinion", {
|
|
|
839
1426
|
}, async ({ prompt, provider, model }) => {
|
|
840
1427
|
const resp = await fetch(buildApiUrl("/llm/second-opinion"), {
|
|
841
1428
|
method: "POST",
|
|
842
|
-
headers:
|
|
1429
|
+
headers: await getPostHeaders(),
|
|
843
1430
|
body: JSON.stringify({
|
|
844
1431
|
repo_name: REPO_NAME,
|
|
845
1432
|
prompt,
|
|
@@ -850,7 +1437,104 @@ registerTool("second_opinion", {
|
|
|
850
1437
|
const text = await handleResponse(resp);
|
|
851
1438
|
return { content: [{ type: "text", text }] };
|
|
852
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
|
+
});
|
|
853
1531
|
registerTool("get_project_standards", {
|
|
1532
|
+
annotations: {
|
|
1533
|
+
readOnlyHint: true,
|
|
1534
|
+
destructiveHint: false,
|
|
1535
|
+
idempotentHint: true,
|
|
1536
|
+
openWorldHint: true,
|
|
1537
|
+
},
|
|
854
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. " +
|
|
855
1539
|
"Returns structured markdown with sections for project context, architecture instructions, code review correctness standards, testing stack information, and build analysis. " +
|
|
856
1540
|
"Only sections with configured values are included. Returns 404 if no standards are configured. " +
|
|
@@ -858,11 +1542,17 @@ registerTool("get_project_standards", {
|
|
|
858
1542
|
inputSchema: {},
|
|
859
1543
|
}, async () => {
|
|
860
1544
|
const url = buildGetUrl("/project-standards", { repo_name: REPO_NAME });
|
|
861
|
-
const resp = await fetch(url, { headers:
|
|
1545
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
862
1546
|
const text = await handleResponse(resp);
|
|
863
1547
|
return { content: [{ type: "text", text }] };
|
|
864
1548
|
});
|
|
865
1549
|
registerTool("get_tickets", {
|
|
1550
|
+
annotations: {
|
|
1551
|
+
readOnlyHint: true,
|
|
1552
|
+
destructiveHint: false,
|
|
1553
|
+
idempotentHint: true,
|
|
1554
|
+
openWorldHint: true,
|
|
1555
|
+
},
|
|
866
1556
|
description: "Search for and list Jira tickets from the configured project. " +
|
|
867
1557
|
"Filters by query text, status name, label, or date. Returns up to 'limit' tickets ordered by most recently updated. " +
|
|
868
1558
|
"All data is fetched live from Jira. Use get_ticket to retrieve full details for a specific ticket.",
|
|
@@ -913,11 +1603,29 @@ registerTool("get_tickets", {
|
|
|
913
1603
|
if (updated_since)
|
|
914
1604
|
params.updated_since = updated_since;
|
|
915
1605
|
const url = buildGetUrl("/tickets", params);
|
|
916
|
-
const resp = await fetch(url, { headers:
|
|
917
|
-
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
|
+
}
|
|
918
1620
|
return { content: [{ type: "text", text }] };
|
|
919
1621
|
});
|
|
920
1622
|
registerTool("get_ticket", {
|
|
1623
|
+
annotations: {
|
|
1624
|
+
readOnlyHint: true,
|
|
1625
|
+
destructiveHint: false,
|
|
1626
|
+
idempotentHint: true,
|
|
1627
|
+
openWorldHint: true,
|
|
1628
|
+
},
|
|
921
1629
|
description: "Retrieve full details for a single Jira ticket by its key. " +
|
|
922
1630
|
"Returns summary, status, type, assignee, reporter, description, and timestamps. " +
|
|
923
1631
|
"All data is fetched live from Jira. Use get_tickets to search/list multiple tickets. " +
|
|
@@ -929,11 +1637,23 @@ registerTool("get_ticket", {
|
|
|
929
1637
|
},
|
|
930
1638
|
}, async ({ ticket_number }) => {
|
|
931
1639
|
const url = buildGetUrl(`/tickets/${encodeURIComponent(ticket_number)}`, { repo_name: REPO_NAME });
|
|
932
|
-
const resp = await fetch(url, { headers:
|
|
933
|
-
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
|
+
}
|
|
934
1648
|
return { content: [{ type: "text", text }] };
|
|
935
1649
|
});
|
|
936
1650
|
registerTool("get_comments", {
|
|
1651
|
+
annotations: {
|
|
1652
|
+
readOnlyHint: true,
|
|
1653
|
+
destructiveHint: false,
|
|
1654
|
+
idempotentHint: true,
|
|
1655
|
+
openWorldHint: true,
|
|
1656
|
+
},
|
|
937
1657
|
description: "Retrieve all comments on a Jira ticket, oldest-first. " +
|
|
938
1658
|
"Returns an array of {id, author, body, created, updated}. " +
|
|
939
1659
|
"Comment bodies are Markdown (converted from Jira wiki markup). " +
|
|
@@ -946,11 +1666,23 @@ registerTool("get_comments", {
|
|
|
946
1666
|
},
|
|
947
1667
|
}, async ({ ticket_number }) => {
|
|
948
1668
|
const url = buildGetUrl(`/tickets/${encodeURIComponent(ticket_number)}/comments`, { repo_name: REPO_NAME });
|
|
949
|
-
const resp = await fetch(url, { headers:
|
|
950
|
-
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
|
+
}
|
|
951
1677
|
return { content: [{ type: "text", text }] };
|
|
952
1678
|
});
|
|
953
1679
|
registerTool("create_ticket", {
|
|
1680
|
+
annotations: {
|
|
1681
|
+
readOnlyHint: false,
|
|
1682
|
+
destructiveHint: false,
|
|
1683
|
+
idempotentHint: false,
|
|
1684
|
+
openWorldHint: true,
|
|
1685
|
+
},
|
|
954
1686
|
description: "Create a new Jira ticket in the configured project. Requires either description or file_path (or both — file_path takes precedence). " +
|
|
955
1687
|
"Returns JSON with {ticket_key: 'PROJ-123', url: 'https://...'}. " +
|
|
956
1688
|
"The ticket is created immediately in Jira — confirm details with the user before calling. " +
|
|
@@ -999,10 +1731,17 @@ registerTool("create_ticket", {
|
|
|
999
1731
|
return { content: [{ type: "text", text: text + resolved.note }] };
|
|
1000
1732
|
});
|
|
1001
1733
|
registerTool("get_plan", {
|
|
1002
|
-
|
|
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. " +
|
|
1003
1742
|
"Returns the full plan as markdown text — present it verbatim without summarizing. " +
|
|
1004
1743
|
"The plan includes step-by-step implementation guidance with code file references. " +
|
|
1005
|
-
"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. " +
|
|
1006
1745
|
"Tip: call get_clarifying_questions for the same ticket to get the full context for implementation.",
|
|
1007
1746
|
inputSchema: {
|
|
1008
1747
|
ticket_number: z
|
|
@@ -1015,23 +1754,21 @@ registerTool("get_plan", {
|
|
|
1015
1754
|
.describe("Whether to save the plan to a local file in the BAPI_DOCS_DIR/plans/ directory. " +
|
|
1016
1755
|
"Defaults to true. Set to false to skip saving."),
|
|
1017
1756
|
},
|
|
1018
|
-
}, async (
|
|
1019
|
-
|
|
1020
|
-
const resp = await fetch(url, { headers: GET_HEADERS });
|
|
1021
|
-
const ok = resp.ok;
|
|
1022
|
-
let text = await handleResponse(resp);
|
|
1023
|
-
if (ok && save_locally) {
|
|
1024
|
-
const note = await saveLocally(getDocsPath("plans"), `${ticket_number}-plan.md`, text);
|
|
1025
|
-
text += note;
|
|
1026
|
-
}
|
|
1027
|
-
return { content: [{ type: "text", text }] };
|
|
1757
|
+
}, async (args) => {
|
|
1758
|
+
return getTicketArtifact("plan", args);
|
|
1028
1759
|
});
|
|
1029
1760
|
registerTool("get_architecture", {
|
|
1030
|
-
|
|
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. " +
|
|
1031
1769
|
"Returns the full architecture plan as markdown text — present it verbatim without summarizing. " +
|
|
1032
1770
|
"The plan includes high-level architectural decisions, component design, and integration guidance. " +
|
|
1033
|
-
"Returns 404
|
|
1034
|
-
"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.",
|
|
1035
1772
|
inputSchema: {
|
|
1036
1773
|
ticket_number: z
|
|
1037
1774
|
.string()
|
|
@@ -1043,21 +1780,20 @@ registerTool("get_architecture", {
|
|
|
1043
1780
|
.describe("Whether to save the architecture plan to a local file in the BAPI_DOCS_DIR/architecture/ directory. " +
|
|
1044
1781
|
"Defaults to true. Set to false to skip saving."),
|
|
1045
1782
|
},
|
|
1046
|
-
}, async (
|
|
1047
|
-
|
|
1048
|
-
const resp = await fetch(url, { headers: GET_HEADERS });
|
|
1049
|
-
const ok = resp.ok;
|
|
1050
|
-
let text = await handleResponse(resp);
|
|
1051
|
-
if (ok && save_locally) {
|
|
1052
|
-
const note = await saveLocally(getDocsPath("architecture"), `${ticket_number}-architecture-plan.md`, text);
|
|
1053
|
-
text += note;
|
|
1054
|
-
}
|
|
1055
|
-
return { content: [{ type: "text", text }] };
|
|
1783
|
+
}, async (args) => {
|
|
1784
|
+
return getTicketArtifact("architecture", args);
|
|
1056
1785
|
});
|
|
1057
1786
|
registerTool("get_clarifying_questions", {
|
|
1058
|
-
|
|
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. " +
|
|
1059
1795
|
"Returns markdown text with questions that should be resolved before implementation begins. " +
|
|
1060
|
-
"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. " +
|
|
1061
1797
|
"Tip: call get_plan for the same ticket to get the implementation plan alongside these questions.",
|
|
1062
1798
|
inputSchema: {
|
|
1063
1799
|
ticket_number: z
|
|
@@ -1070,18 +1806,16 @@ registerTool("get_clarifying_questions", {
|
|
|
1070
1806
|
.describe("Whether to save the clarifying questions to a local file in the BAPI_DOCS_DIR/clarifying-questions/ directory. " +
|
|
1071
1807
|
"Defaults to true. Set to false to skip saving."),
|
|
1072
1808
|
},
|
|
1073
|
-
}, async (
|
|
1074
|
-
|
|
1075
|
-
const resp = await fetch(url, { headers: GET_HEADERS });
|
|
1076
|
-
const ok = resp.ok;
|
|
1077
|
-
let text = await handleResponse(resp);
|
|
1078
|
-
if (ok && save_locally) {
|
|
1079
|
-
const note = await saveLocally(getDocsPath("clarifying-questions"), `${ticket_number}-clarifying-questions.md`, text);
|
|
1080
|
-
text += note;
|
|
1081
|
-
}
|
|
1082
|
-
return { content: [{ type: "text", text }] };
|
|
1809
|
+
}, async (args) => {
|
|
1810
|
+
return getTicketArtifact("clarifying_questions", args);
|
|
1083
1811
|
});
|
|
1084
1812
|
registerTool("parse_repository", {
|
|
1813
|
+
annotations: {
|
|
1814
|
+
readOnlyHint: false,
|
|
1815
|
+
destructiveHint: false,
|
|
1816
|
+
idempotentHint: true,
|
|
1817
|
+
openWorldHint: true,
|
|
1818
|
+
},
|
|
1085
1819
|
description: "Queue a background job to parse and index the repository for Bridge API's AI agents. " +
|
|
1086
1820
|
"This should be run after major codebase changes so that plans and questions reflect the latest code. " +
|
|
1087
1821
|
"Returns 202 with {message: 'Repository parsing queued'} on success, " +
|
|
@@ -1101,13 +1835,19 @@ registerTool("parse_repository", {
|
|
|
1101
1835
|
payload.directory_path = directory_path;
|
|
1102
1836
|
const resp = await fetch(buildUrl("/parse-repository"), {
|
|
1103
1837
|
method: "POST",
|
|
1104
|
-
headers:
|
|
1838
|
+
headers: await getPostHeaders(),
|
|
1105
1839
|
body: JSON.stringify(payload),
|
|
1106
1840
|
});
|
|
1107
1841
|
const text = await handleResponse(resp);
|
|
1108
1842
|
return { content: [{ type: "text", text }] };
|
|
1109
1843
|
});
|
|
1110
1844
|
registerTool("regenerate_directory_map", {
|
|
1845
|
+
annotations: {
|
|
1846
|
+
readOnlyHint: false,
|
|
1847
|
+
destructiveHint: false,
|
|
1848
|
+
idempotentHint: true,
|
|
1849
|
+
openWorldHint: true,
|
|
1850
|
+
},
|
|
1111
1851
|
description: "Regenerate the repository directory map and return the result. " +
|
|
1112
1852
|
"Unlike parse_repository (which is async), this tool is synchronous — it blocks until " +
|
|
1113
1853
|
"the directory map is generated and returns the full map text directly. " +
|
|
@@ -1120,7 +1860,7 @@ registerTool("regenerate_directory_map", {
|
|
|
1120
1860
|
try {
|
|
1121
1861
|
const resp = await fetch(buildUrl("/regenerate-directory-map"), {
|
|
1122
1862
|
method: "POST",
|
|
1123
|
-
headers:
|
|
1863
|
+
headers: await getPostHeaders(),
|
|
1124
1864
|
body: JSON.stringify({ repo_name: REPO_NAME }),
|
|
1125
1865
|
signal: controller.signal,
|
|
1126
1866
|
});
|
|
@@ -1132,6 +1872,12 @@ registerTool("regenerate_directory_map", {
|
|
|
1132
1872
|
}
|
|
1133
1873
|
});
|
|
1134
1874
|
registerTool("get_parse_status", {
|
|
1875
|
+
annotations: {
|
|
1876
|
+
readOnlyHint: true,
|
|
1877
|
+
destructiveHint: false,
|
|
1878
|
+
idempotentHint: true,
|
|
1879
|
+
openWorldHint: true,
|
|
1880
|
+
},
|
|
1135
1881
|
description: "Check whether a repository parse job is currently running. " +
|
|
1136
1882
|
"Returns {status: 'in_progress', started_at: '<ISO timestamp>'} if a parse is active, " +
|
|
1137
1883
|
"or {status: 'idle'} if no parse is running. " +
|
|
@@ -1140,11 +1886,17 @@ registerTool("get_parse_status", {
|
|
|
1140
1886
|
inputSchema: {},
|
|
1141
1887
|
}, async () => {
|
|
1142
1888
|
const url = buildGetUrl("/parse-status", { repo_name: REPO_NAME });
|
|
1143
|
-
const resp = await fetch(url, { headers:
|
|
1889
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
1144
1890
|
const text = await handleResponse(resp);
|
|
1145
1891
|
return { content: [{ type: "text", text }] };
|
|
1146
1892
|
});
|
|
1147
1893
|
registerTool("add_comment", {
|
|
1894
|
+
annotations: {
|
|
1895
|
+
readOnlyHint: false,
|
|
1896
|
+
destructiveHint: false,
|
|
1897
|
+
idempotentHint: false,
|
|
1898
|
+
openWorldHint: true,
|
|
1899
|
+
},
|
|
1148
1900
|
description: "Post a comment on a Jira ticket. The comment appears immediately in Jira. " +
|
|
1149
1901
|
"Supports markdown formatting. " +
|
|
1150
1902
|
"For long comments (over ~2000 characters), set attach_as_file to true — " +
|
|
@@ -1192,13 +1944,19 @@ registerTool("add_comment", {
|
|
|
1192
1944
|
}
|
|
1193
1945
|
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/comment`), {
|
|
1194
1946
|
method: "POST",
|
|
1195
|
-
headers:
|
|
1947
|
+
headers: await getPostHeaders(),
|
|
1196
1948
|
body: JSON.stringify(payload),
|
|
1197
1949
|
});
|
|
1198
1950
|
const text = await handleResponse(resp);
|
|
1199
1951
|
return { content: [{ type: "text", text: text + resolved.note }] };
|
|
1200
1952
|
});
|
|
1201
1953
|
registerTool("update_ticket_description", {
|
|
1954
|
+
annotations: {
|
|
1955
|
+
readOnlyHint: false,
|
|
1956
|
+
destructiveHint: true,
|
|
1957
|
+
idempotentHint: true,
|
|
1958
|
+
openWorldHint: true,
|
|
1959
|
+
},
|
|
1202
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. " +
|
|
1203
1961
|
"The description should be in markdown format — it will be automatically converted to Jira wiki markup. " +
|
|
1204
1962
|
"This does NOT create a new ticket. Use create_ticket for that. " +
|
|
@@ -1224,13 +1982,19 @@ registerTool("update_ticket_description", {
|
|
|
1224
1982
|
return resolved.errorResponse;
|
|
1225
1983
|
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/description`), {
|
|
1226
1984
|
method: "PUT",
|
|
1227
|
-
headers:
|
|
1985
|
+
headers: await getPostHeaders(),
|
|
1228
1986
|
body: JSON.stringify({ repo_name: REPO_NAME, description: resolved.text }),
|
|
1229
1987
|
});
|
|
1230
1988
|
const text = await handleResponse(resp);
|
|
1231
1989
|
return { content: [{ type: "text", text: text + resolved.note }] };
|
|
1232
1990
|
});
|
|
1233
1991
|
registerTool("upload_attachment", {
|
|
1992
|
+
annotations: {
|
|
1993
|
+
readOnlyHint: false,
|
|
1994
|
+
destructiveHint: false,
|
|
1995
|
+
idempotentHint: false,
|
|
1996
|
+
openWorldHint: true,
|
|
1997
|
+
},
|
|
1234
1998
|
description: "Upload a local file as an attachment to a Jira ticket. " +
|
|
1235
1999
|
"Supports text/UTF-8 files only (markdown, plain text, etc.). " +
|
|
1236
2000
|
"Optionally syncs the content to Bridge API's tickets_links table so retrieval endpoints " +
|
|
@@ -1287,13 +2051,19 @@ registerTool("upload_attachment", {
|
|
|
1287
2051
|
}
|
|
1288
2052
|
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/attachment`), {
|
|
1289
2053
|
method: "POST",
|
|
1290
|
-
headers:
|
|
2054
|
+
headers: await getPostHeaders(),
|
|
1291
2055
|
body: JSON.stringify(payload),
|
|
1292
2056
|
});
|
|
1293
2057
|
const text = await handleResponse(resp);
|
|
1294
2058
|
return { content: [{ type: "text", text: text + resolved.note }] };
|
|
1295
2059
|
});
|
|
1296
2060
|
registerTool("list_attachments", {
|
|
2061
|
+
annotations: {
|
|
2062
|
+
readOnlyHint: true,
|
|
2063
|
+
destructiveHint: false,
|
|
2064
|
+
idempotentHint: true,
|
|
2065
|
+
openWorldHint: true,
|
|
2066
|
+
},
|
|
1297
2067
|
description: "List attachments on a Jira ticket. " +
|
|
1298
2068
|
"Returns metadata (ID, filename, MIME type, size, created date) for each attachment. " +
|
|
1299
2069
|
"By default, AI-generated attachments are excluded. " +
|
|
@@ -1313,12 +2083,23 @@ registerTool("list_attachments", {
|
|
|
1313
2083
|
params.include_ai_generated = "true";
|
|
1314
2084
|
}
|
|
1315
2085
|
const url = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/attachments`, params);
|
|
1316
|
-
const resp = await fetch(url, { headers:
|
|
1317
|
-
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
|
+
}
|
|
1318
2094
|
return { content: [{ type: "text", text }] };
|
|
1319
2095
|
});
|
|
1320
|
-
const MAX_INLINE_TEXT_LENGTH = 50_000;
|
|
1321
2096
|
registerTool("download_attachment", {
|
|
2097
|
+
annotations: {
|
|
2098
|
+
readOnlyHint: true,
|
|
2099
|
+
destructiveHint: false,
|
|
2100
|
+
idempotentHint: true,
|
|
2101
|
+
openWorldHint: true,
|
|
2102
|
+
},
|
|
1322
2103
|
description: "Download an attachment from a Jira ticket and save it to disk. " +
|
|
1323
2104
|
"Specify either attachment_id or filename (not both). " +
|
|
1324
2105
|
"For text files, the content is returned inline (truncated at ~50KB) and also saved to disk. " +
|
|
@@ -1359,7 +2140,7 @@ registerTool("download_attachment", {
|
|
|
1359
2140
|
if (filename)
|
|
1360
2141
|
params.filename = filename;
|
|
1361
2142
|
const url = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/attachments/download`, params);
|
|
1362
|
-
const resp = await fetch(url, { headers:
|
|
2143
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
1363
2144
|
if (!resp.ok) {
|
|
1364
2145
|
const text = await handleResponse(resp);
|
|
1365
2146
|
return { content: [{ type: "text", text }] };
|
|
@@ -1374,9 +2155,9 @@ registerTool("download_attachment", {
|
|
|
1374
2155
|
const safeTicket = path.basename(ticket_number);
|
|
1375
2156
|
const savePath = file_path
|
|
1376
2157
|
? file_path
|
|
1377
|
-
: path.join(
|
|
2158
|
+
: path.join(await getDocsDir(), "attachments", safeTicket, safeFileName);
|
|
1378
2159
|
const resolvedSave = path.resolve(savePath);
|
|
1379
|
-
const resolvedRoot = path.resolve(
|
|
2160
|
+
const resolvedRoot = path.resolve(await getProjectRoot());
|
|
1380
2161
|
if (!resolvedSave.startsWith(resolvedRoot + path.sep) && resolvedSave !== resolvedRoot) {
|
|
1381
2162
|
return {
|
|
1382
2163
|
content: [{
|
|
@@ -1407,10 +2188,16 @@ registerTool("download_attachment", {
|
|
|
1407
2188
|
return { content: [{ type: "text", text: resultText }] };
|
|
1408
2189
|
});
|
|
1409
2190
|
registerTool("request_plan_generation", {
|
|
1410
|
-
|
|
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. " +
|
|
1411
2198
|
"This triggers an asynchronous background job — results are NOT immediate. " +
|
|
1412
2199
|
"Processing typically takes 1-5 minutes depending on ticket complexity and number of attachments. " +
|
|
1413
|
-
"
|
|
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. " +
|
|
1414
2201
|
"Returns 202 if the request was accepted, 404 if the ticket does not exist in Jira, " +
|
|
1415
2202
|
"or 403 if the API key is unauthorized. " +
|
|
1416
2203
|
"Set wait_for_result to true to block until the result is ready (typically 1-5 minutes) instead of returning immediately.",
|
|
@@ -1431,124 +2218,82 @@ registerTool("request_plan_generation", {
|
|
|
1431
2218
|
.default(true)
|
|
1432
2219
|
.describe("When wait_for_result is true, whether to save the plan to a local file in the " +
|
|
1433
2220
|
"BAPI_DOCS_DIR/plans/ directory. Defaults to true. Ignored when wait_for_result is false."),
|
|
1434
|
-
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."),
|
|
1435
2231
|
provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
|
|
1436
2232
|
"triggering second-opinion semantics. If both provider and second_opinion are set, " +
|
|
1437
2233
|
"second_opinion takes precedence."),
|
|
1438
2234
|
},
|
|
1439
|
-
}, async (
|
|
1440
|
-
|
|
1441
|
-
const trimmedSecondOpinion = second_opinion?.trim();
|
|
1442
|
-
if (trimmedSecondOpinion) {
|
|
1443
|
-
body.provider_override = trimmedSecondOpinion;
|
|
1444
|
-
}
|
|
1445
|
-
const trimmedProvider = provider?.trim();
|
|
1446
|
-
if (trimmedProvider && !trimmedSecondOpinion) {
|
|
1447
|
-
body.provider = trimmedProvider;
|
|
1448
|
-
}
|
|
1449
|
-
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/generate-plan`), {
|
|
1450
|
-
method: "POST",
|
|
1451
|
-
headers: POST_HEADERS,
|
|
1452
|
-
body: JSON.stringify(body),
|
|
1453
|
-
});
|
|
1454
|
-
if (!resp.ok) {
|
|
1455
|
-
const errorText = await handleResponse(resp);
|
|
1456
|
-
return {
|
|
1457
|
-
content: [{ type: "text", text: `Failed to request plan generation: ${errorText}` }],
|
|
1458
|
-
};
|
|
1459
|
-
}
|
|
1460
|
-
if (wait_for_result) {
|
|
1461
|
-
const getUrl = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/plan`, { repo_name: REPO_NAME });
|
|
1462
|
-
const result = await pollForResult(getUrl, 900_000, `Plan generation for ${ticket_number}`);
|
|
1463
|
-
if (!result.ok) {
|
|
1464
|
-
return { content: [{ type: "text", text: result.text }] };
|
|
1465
|
-
}
|
|
1466
|
-
let text = result.text;
|
|
1467
|
-
if (save_locally) {
|
|
1468
|
-
const note = await saveLocally(getDocsPath("plans"), `${ticket_number}-plan.md`, text);
|
|
1469
|
-
text += note;
|
|
1470
|
-
}
|
|
1471
|
-
return { content: [{ type: "text", text }] };
|
|
1472
|
-
}
|
|
1473
|
-
const confirmationText = `Plan generation requested for ${ticket_number}. ` +
|
|
1474
|
-
`Processing typically takes 1-5 minutes. ` +
|
|
1475
|
-
`Use get_plan with ticket_number "${ticket_number}" to retrieve the plan once processing completes.`;
|
|
1476
|
-
return { content: [{ type: "text", text: confirmationText }] };
|
|
2235
|
+
}, async (args) => {
|
|
2236
|
+
return requestTicketArtifact("plan", args);
|
|
1477
2237
|
});
|
|
1478
2238
|
registerTool("request_architecture", {
|
|
1479
|
-
|
|
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. " +
|
|
1480
2246
|
"This triggers an asynchronous background job — results are NOT immediate. " +
|
|
1481
2247
|
"Processing typically takes 2-4 minutes depending on ticket complexity. " +
|
|
1482
|
-
"
|
|
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. " +
|
|
1483
2249
|
"Returns 202 if the request was accepted, 404 if the ticket does not exist in Jira, " +
|
|
1484
2250
|
"or 403 if the API key is unauthorized. " +
|
|
1485
2251
|
"Set wait_for_result to true to block until the result is ready (typically 2-4 minutes) instead of returning immediately.",
|
|
1486
2252
|
inputSchema: {
|
|
1487
2253
|
ticket_number: z
|
|
1488
2254
|
.string()
|
|
1489
|
-
.describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123) to generate an architecture plan for"),
|
|
1490
|
-
wait_for_result: z
|
|
1491
|
-
.boolean()
|
|
1492
|
-
.optional()
|
|
1493
|
-
.default(false)
|
|
1494
|
-
.describe("When true, the tool blocks and polls until the architecture plan is ready (typically 2-4 minutes), " +
|
|
1495
|
-
"then returns the full plan content directly. When false (default), returns immediately " +
|
|
1496
|
-
"with a confirmation message — use get_architecture later to retrieve results."),
|
|
1497
|
-
save_locally: z
|
|
1498
|
-
.boolean()
|
|
1499
|
-
.optional()
|
|
1500
|
-
.default(true)
|
|
1501
|
-
.describe("When wait_for_result is true, whether to save the architecture plan to a local file in the " +
|
|
1502
|
-
"BAPI_DOCS_DIR/architecture/ directory. Defaults to true. Only takes effect when wait_for_result is true."),
|
|
1503
|
-
second_opinion: z
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
"
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
method: "POST",
|
|
1520
|
-
headers: POST_HEADERS,
|
|
1521
|
-
body: JSON.stringify(body),
|
|
1522
|
-
});
|
|
1523
|
-
if (!resp.ok) {
|
|
1524
|
-
const errorText = await handleResponse(resp);
|
|
1525
|
-
return {
|
|
1526
|
-
content: [{ type: "text", text: `Failed to request architecture generation: ${errorText}` }],
|
|
1527
|
-
};
|
|
1528
|
-
}
|
|
1529
|
-
if (wait_for_result) {
|
|
1530
|
-
const getUrl = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/architecture-plan`, { repo_name: REPO_NAME });
|
|
1531
|
-
const result = await pollForResult(getUrl, 900_000, `Architecture generation for ${ticket_number}`);
|
|
1532
|
-
if (!result.ok) {
|
|
1533
|
-
return { content: [{ type: "text", text: result.text }] };
|
|
1534
|
-
}
|
|
1535
|
-
let text = result.text;
|
|
1536
|
-
if (save_locally) {
|
|
1537
|
-
const note = await saveLocally(getDocsPath("architecture"), `${ticket_number}-architecture-plan.md`, text);
|
|
1538
|
-
text += note;
|
|
1539
|
-
}
|
|
1540
|
-
return { content: [{ type: "text", text }] };
|
|
1541
|
-
}
|
|
1542
|
-
const confirmationText = `Architecture generation requested for ${ticket_number}. ` +
|
|
1543
|
-
`Processing typically takes 2-4 minutes. ` +
|
|
1544
|
-
`Use get_architecture with ticket_number "${ticket_number}" to retrieve the architecture plan once processing completes.`;
|
|
1545
|
-
return { content: [{ type: "text", text: confirmationText }] };
|
|
2255
|
+
.describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123) to generate an architecture plan for"),
|
|
2256
|
+
wait_for_result: z
|
|
2257
|
+
.boolean()
|
|
2258
|
+
.optional()
|
|
2259
|
+
.default(false)
|
|
2260
|
+
.describe("When true, the tool blocks and polls until the architecture plan is ready (typically 2-4 minutes), " +
|
|
2261
|
+
"then returns the full plan content directly. When false (default), returns immediately " +
|
|
2262
|
+
"with a confirmation message — use get_architecture later to retrieve results."),
|
|
2263
|
+
save_locally: z
|
|
2264
|
+
.boolean()
|
|
2265
|
+
.optional()
|
|
2266
|
+
.default(true)
|
|
2267
|
+
.describe("When wait_for_result is true, whether to save the architecture plan to a local file in the " +
|
|
2268
|
+
"BAPI_DOCS_DIR/architecture/ directory. Defaults to true. Only takes effect when wait_for_result is true."),
|
|
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."),
|
|
2279
|
+
provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
|
|
2280
|
+
"triggering second-opinion semantics. If both provider and second_opinion are set, " +
|
|
2281
|
+
"second_opinion takes precedence."),
|
|
2282
|
+
},
|
|
2283
|
+
}, async (args) => {
|
|
2284
|
+
return requestTicketArtifact("architecture", args);
|
|
1546
2285
|
});
|
|
1547
2286
|
registerTool("request_clarifying_questions", {
|
|
1548
|
-
|
|
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. " +
|
|
1549
2294
|
"This triggers an asynchronous background job — results are NOT immediate. " +
|
|
1550
2295
|
"Processing typically takes 1-5 minutes. " +
|
|
1551
|
-
"
|
|
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. " +
|
|
1552
2297
|
"For bug tickets, the result may be debugging guidance instead of clarifying questions. " +
|
|
1553
2298
|
"Returns 202 if the request was accepted, 404 if the ticket does not exist in Jira, " +
|
|
1554
2299
|
"or 403 if the API key is unauthorized. " +
|
|
@@ -1570,59 +2315,38 @@ registerTool("request_clarifying_questions", {
|
|
|
1570
2315
|
.default(true)
|
|
1571
2316
|
.describe("When wait_for_result is true, whether to save the clarifying questions to a local file in the " +
|
|
1572
2317
|
"BAPI_DOCS_DIR/clarifying-questions/ directory. Defaults to true. Ignored when wait_for_result is false."),
|
|
1573
|
-
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."),
|
|
1574
2328
|
provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
|
|
1575
2329
|
"triggering second-opinion semantics. If both provider and second_opinion are set, " +
|
|
1576
2330
|
"second_opinion takes precedence."),
|
|
1577
2331
|
},
|
|
1578
|
-
}, async (
|
|
1579
|
-
|
|
1580
|
-
const trimmedSecondOpinion = second_opinion?.trim();
|
|
1581
|
-
if (trimmedSecondOpinion) {
|
|
1582
|
-
body.provider_override = trimmedSecondOpinion;
|
|
1583
|
-
}
|
|
1584
|
-
const trimmedProvider = provider?.trim();
|
|
1585
|
-
if (trimmedProvider && !trimmedSecondOpinion) {
|
|
1586
|
-
body.provider = trimmedProvider;
|
|
1587
|
-
}
|
|
1588
|
-
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/generate-clarifying-questions`), {
|
|
1589
|
-
method: "POST",
|
|
1590
|
-
headers: POST_HEADERS,
|
|
1591
|
-
body: JSON.stringify(body),
|
|
1592
|
-
});
|
|
1593
|
-
if (!resp.ok) {
|
|
1594
|
-
const errorText = await handleResponse(resp);
|
|
1595
|
-
return {
|
|
1596
|
-
content: [{ type: "text", text: `Failed to request clarifying questions: ${errorText}` }],
|
|
1597
|
-
};
|
|
1598
|
-
}
|
|
1599
|
-
if (wait_for_result) {
|
|
1600
|
-
const getUrl = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/clarifying-questions`, { repo_name: REPO_NAME });
|
|
1601
|
-
const result = await pollForResult(getUrl, 900_000, `Clarifying questions for ${ticket_number}`);
|
|
1602
|
-
if (!result.ok) {
|
|
1603
|
-
return { content: [{ type: "text", text: result.text }] };
|
|
1604
|
-
}
|
|
1605
|
-
let text = result.text;
|
|
1606
|
-
if (save_locally) {
|
|
1607
|
-
const note = await saveLocally(getDocsPath("clarifying-questions"), `${ticket_number}-clarifying-questions.md`, text);
|
|
1608
|
-
text += note;
|
|
1609
|
-
}
|
|
1610
|
-
return { content: [{ type: "text", text }] };
|
|
1611
|
-
}
|
|
1612
|
-
const confirmationText = `Clarifying questions requested for ${ticket_number}. ` +
|
|
1613
|
-
`Processing typically takes 1-5 minutes. ` +
|
|
1614
|
-
`Use get_clarifying_questions with ticket_number "${ticket_number}" to retrieve the results once processing completes.`;
|
|
1615
|
-
return { content: [{ type: "text", text: confirmationText }] };
|
|
2332
|
+
}, async (args) => {
|
|
2333
|
+
return requestTicketArtifact("clarifying_questions", args);
|
|
1616
2334
|
});
|
|
1617
2335
|
// ---------------------------------------------------------------------------
|
|
1618
2336
|
// Ticket Quality Critique
|
|
1619
2337
|
// ---------------------------------------------------------------------------
|
|
1620
2338
|
registerTool("get_ticket_critique", {
|
|
1621
|
-
|
|
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. " +
|
|
1622
2347
|
"Returns markdown text with a structured critique covering Standards Conformance Analysis, " +
|
|
1623
2348
|
"Standards Deviations, and Suggested Improvements. " +
|
|
1624
|
-
"Returns 404
|
|
1625
|
-
"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.",
|
|
1626
2350
|
inputSchema: {
|
|
1627
2351
|
ticket_number: z
|
|
1628
2352
|
.string()
|
|
@@ -1634,22 +2358,20 @@ registerTool("get_ticket_critique", {
|
|
|
1634
2358
|
.describe("Whether to save the critique to a local file in the BAPI_DOCS_DIR/ticket-critiques/ directory. " +
|
|
1635
2359
|
"Defaults to true. Set to false to skip saving."),
|
|
1636
2360
|
},
|
|
1637
|
-
}, async (
|
|
1638
|
-
|
|
1639
|
-
const resp = await fetch(url, { headers: GET_HEADERS });
|
|
1640
|
-
const ok = resp.ok;
|
|
1641
|
-
let text = await handleResponse(resp);
|
|
1642
|
-
if (ok && save_locally) {
|
|
1643
|
-
const note = await saveLocally(getDocsPath("ticket-critiques"), `${ticket_number}-ticket-quality-critique.md`, text);
|
|
1644
|
-
text += note;
|
|
1645
|
-
}
|
|
1646
|
-
return { content: [{ type: "text", text }] };
|
|
2361
|
+
}, async (args) => {
|
|
2362
|
+
return getTicketArtifact("ticket_critique", args);
|
|
1647
2363
|
});
|
|
1648
2364
|
registerTool("request_ticket_critique", {
|
|
1649
|
-
|
|
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. " +
|
|
1650
2372
|
"This triggers an asynchronous background job — results are NOT immediate. " +
|
|
1651
2373
|
"Processing typically takes 1-5 minutes. " +
|
|
1652
|
-
"
|
|
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. " +
|
|
1653
2375
|
"Returns 202 if the request was accepted, 404 if the ticket does not exist in Jira, " +
|
|
1654
2376
|
"or 403 if the API key is unauthorized. " +
|
|
1655
2377
|
"Set wait_for_result to true to block until the result is ready (typically 1-5 minutes) instead of returning immediately.",
|
|
@@ -1670,54 +2392,33 @@ registerTool("request_ticket_critique", {
|
|
|
1670
2392
|
.default(true)
|
|
1671
2393
|
.describe("When wait_for_result is true, whether to save the ticket critique to a local file in the " +
|
|
1672
2394
|
"BAPI_DOCS_DIR/ticket-critiques/ directory. Defaults to true. Ignored when wait_for_result is false."),
|
|
1673
|
-
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."),
|
|
1674
2405
|
provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
|
|
1675
2406
|
"triggering second-opinion semantics. If both provider and second_opinion are set, " +
|
|
1676
2407
|
"second_opinion takes precedence."),
|
|
1677
2408
|
},
|
|
1678
|
-
}, async (
|
|
1679
|
-
|
|
1680
|
-
const trimmedSecondOpinion = second_opinion?.trim();
|
|
1681
|
-
if (trimmedSecondOpinion) {
|
|
1682
|
-
body.provider_override = trimmedSecondOpinion;
|
|
1683
|
-
}
|
|
1684
|
-
const trimmedProvider = provider?.trim();
|
|
1685
|
-
if (trimmedProvider && !trimmedSecondOpinion) {
|
|
1686
|
-
body.provider = trimmedProvider;
|
|
1687
|
-
}
|
|
1688
|
-
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/generate-ticket-critique`), {
|
|
1689
|
-
method: "POST",
|
|
1690
|
-
headers: POST_HEADERS,
|
|
1691
|
-
body: JSON.stringify(body),
|
|
1692
|
-
});
|
|
1693
|
-
if (!resp.ok) {
|
|
1694
|
-
const errorText = await handleResponse(resp);
|
|
1695
|
-
return {
|
|
1696
|
-
content: [{ type: "text", text: `Failed to request ticket critique: ${errorText}` }],
|
|
1697
|
-
};
|
|
1698
|
-
}
|
|
1699
|
-
if (wait_for_result) {
|
|
1700
|
-
const getUrl = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/ticket-critique`, { repo_name: REPO_NAME });
|
|
1701
|
-
const result = await pollForResult(getUrl, 900_000, `Ticket critique for ${ticket_number}`);
|
|
1702
|
-
if (!result.ok) {
|
|
1703
|
-
return { content: [{ type: "text", text: result.text }] };
|
|
1704
|
-
}
|
|
1705
|
-
let text = result.text;
|
|
1706
|
-
if (save_locally) {
|
|
1707
|
-
const note = await saveLocally(getDocsPath("ticket-critiques"), `${ticket_number}-ticket-quality-critique.md`, text);
|
|
1708
|
-
text += note;
|
|
1709
|
-
}
|
|
1710
|
-
return { content: [{ type: "text", text }] };
|
|
1711
|
-
}
|
|
1712
|
-
const confirmationText = `Ticket critique requested for ${ticket_number}. ` +
|
|
1713
|
-
`Processing typically takes 1-5 minutes. ` +
|
|
1714
|
-
`Use get_ticket_critique with ticket_number "${ticket_number}" to retrieve the results once processing completes.`;
|
|
1715
|
-
return { content: [{ type: "text", text: confirmationText }] };
|
|
2409
|
+
}, async (args) => {
|
|
2410
|
+
return requestTicketArtifact("ticket_critique", args);
|
|
1716
2411
|
});
|
|
1717
2412
|
// ---------------------------------------------------------------------------
|
|
1718
2413
|
// Combined Ticket Review (clarify + critique)
|
|
1719
2414
|
// ---------------------------------------------------------------------------
|
|
1720
2415
|
registerTool("request_ticket_review", {
|
|
2416
|
+
annotations: {
|
|
2417
|
+
readOnlyHint: false,
|
|
2418
|
+
destructiveHint: false,
|
|
2419
|
+
idempotentHint: false,
|
|
2420
|
+
openWorldHint: true,
|
|
2421
|
+
},
|
|
1721
2422
|
description: "Request a combined ticket review that generates BOTH clarifying questions (or debugging guidance for bug tickets) " +
|
|
1722
2423
|
"AND a ticket quality critique in parallel on the server, halving wall-clock latency vs. running the two " +
|
|
1723
2424
|
"requests sequentially. This triggers an asynchronous background job — results are NOT immediate. " +
|
|
@@ -1742,93 +2443,37 @@ registerTool("request_ticket_review", {
|
|
|
1742
2443
|
.default(true)
|
|
1743
2444
|
.describe("When wait_for_result is true, whether to save each generated document to its canonical local path under " +
|
|
1744
2445
|
"BAPI_DOCS_DIR (clarifying-questions/ or ticket-critiques/). Defaults to true. Ignored when wait_for_result is false."),
|
|
1745
|
-
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."),
|
|
1746
2456
|
provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
|
|
1747
2457
|
"triggering second-opinion semantics. If both provider and second_opinion are set, " +
|
|
1748
2458
|
"second_opinion takes precedence."),
|
|
1749
2459
|
},
|
|
1750
|
-
}, async (
|
|
1751
|
-
|
|
1752
|
-
const trimmedSecondOpinion = second_opinion?.trim();
|
|
1753
|
-
if (trimmedSecondOpinion) {
|
|
1754
|
-
body.provider_override = trimmedSecondOpinion;
|
|
1755
|
-
}
|
|
1756
|
-
const trimmedProvider = provider?.trim();
|
|
1757
|
-
if (trimmedProvider && !trimmedSecondOpinion) {
|
|
1758
|
-
body.provider = trimmedProvider;
|
|
1759
|
-
}
|
|
1760
|
-
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/generate-ticket-review`), {
|
|
1761
|
-
method: "POST",
|
|
1762
|
-
headers: POST_HEADERS,
|
|
1763
|
-
body: JSON.stringify(body),
|
|
1764
|
-
});
|
|
1765
|
-
if (!resp.ok) {
|
|
1766
|
-
const errorText = await handleResponse(resp);
|
|
1767
|
-
return {
|
|
1768
|
-
content: [{ type: "text", text: `Failed to request ticket review: ${errorText}` }],
|
|
1769
|
-
};
|
|
1770
|
-
}
|
|
1771
|
-
if (wait_for_result) {
|
|
1772
|
-
const getUrl = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/ticket-review`, { repo_name: REPO_NAME });
|
|
1773
|
-
const result = await pollForResult(getUrl, 900_000, `Ticket review for ${ticket_number}`);
|
|
1774
|
-
if (!result.ok) {
|
|
1775
|
-
return { content: [{ type: "text", text: result.text }] };
|
|
1776
|
-
}
|
|
1777
|
-
// The combined GET endpoint returns JSON. Parse and fan out into markdown.
|
|
1778
|
-
let envelope;
|
|
1779
|
-
try {
|
|
1780
|
-
envelope = JSON.parse(result.text);
|
|
1781
|
-
}
|
|
1782
|
-
catch (parseErr) {
|
|
1783
|
-
return {
|
|
1784
|
-
content: [{
|
|
1785
|
-
type: "text",
|
|
1786
|
-
text: `Failed to parse ticket review response: ${parseErr}\nRaw body: ${result.text}`,
|
|
1787
|
-
}],
|
|
1788
|
-
};
|
|
1789
|
-
}
|
|
1790
|
-
const clarify = envelope.clarify ?? {};
|
|
1791
|
-
const critique = envelope.critique ?? {};
|
|
1792
|
-
const parts = [];
|
|
1793
|
-
let notes = "";
|
|
1794
|
-
if (clarify.status === "success" && typeof clarify.content === "string" && clarify.content) {
|
|
1795
|
-
parts.push(clarify.content);
|
|
1796
|
-
if (save_locally && clarify.doc_type) {
|
|
1797
|
-
const filename = `${ticket_number}-${clarify.doc_type}`;
|
|
1798
|
-
const note = await saveLocally(getDocsPath("clarifying-questions"), filename, clarify.content);
|
|
1799
|
-
notes += note;
|
|
1800
|
-
}
|
|
1801
|
-
}
|
|
1802
|
-
else {
|
|
1803
|
-
parts.push(`> **Note:** Clarifying questions sub-flow ${clarify.status ?? "unavailable"} (no content returned).`);
|
|
1804
|
-
}
|
|
1805
|
-
if (critique.status === "success" && typeof critique.content === "string" && critique.content) {
|
|
1806
|
-
parts.push(critique.content);
|
|
1807
|
-
if (save_locally && critique.doc_type) {
|
|
1808
|
-
const filename = `${ticket_number}-${critique.doc_type}`;
|
|
1809
|
-
const note = await saveLocally(getDocsPath("ticket-critiques"), filename, critique.content);
|
|
1810
|
-
notes += note;
|
|
1811
|
-
}
|
|
1812
|
-
}
|
|
1813
|
-
else {
|
|
1814
|
-
parts.push(`> **Note:** Ticket critique sub-flow ${critique.status ?? "unavailable"} (no content returned).`);
|
|
1815
|
-
}
|
|
1816
|
-
const text = parts.join("\n\n---\n\n") + notes;
|
|
1817
|
-
return { content: [{ type: "text", text }] };
|
|
1818
|
-
}
|
|
1819
|
-
const confirmationText = `Combined ticket review requested for ${ticket_number}. ` +
|
|
1820
|
-
`Processing typically takes 2-6 minutes. ` +
|
|
1821
|
-
`Use get_clarifying_questions and get_ticket_critique with ticket_number "${ticket_number}" to retrieve the two documents once processing completes.`;
|
|
1822
|
-
return { content: [{ type: "text", text: confirmationText }] };
|
|
2460
|
+
}, async (args) => {
|
|
2461
|
+
return requestTicketReview(args);
|
|
1823
2462
|
});
|
|
1824
2463
|
// ---------------------------------------------------------------------------
|
|
1825
2464
|
// Reimplement Context
|
|
1826
2465
|
// ---------------------------------------------------------------------------
|
|
1827
2466
|
registerTool("request_reimplement_context", {
|
|
1828
|
-
|
|
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. " +
|
|
1829
2474
|
"Use this for follow-up requests on tickets that have already been through the plan+implement cycle. " +
|
|
1830
2475
|
"This triggers an asynchronous background job to process new attachments/images. " +
|
|
1831
|
-
"
|
|
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. " +
|
|
1832
2477
|
"Set wait_for_result to true to block until the context is ready instead of returning immediately.",
|
|
1833
2478
|
inputSchema: {
|
|
1834
2479
|
ticket_number: z
|
|
@@ -1846,56 +2491,35 @@ registerTool("request_reimplement_context", {
|
|
|
1846
2491
|
.default(true)
|
|
1847
2492
|
.describe("When wait_for_result is true, save the context markdown to " +
|
|
1848
2493
|
"BAPI_DOCS_DIR/reimplementations/{ticket_number}-context.md. Defaults to true."),
|
|
1849
|
-
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."),
|
|
1850
2504
|
provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
|
|
1851
2505
|
"triggering second-opinion semantics. If both provider and second_opinion are set, " +
|
|
1852
2506
|
"second_opinion takes precedence."),
|
|
1853
2507
|
},
|
|
1854
|
-
}, async (
|
|
1855
|
-
|
|
1856
|
-
const trimmedSecondOpinion = second_opinion?.trim();
|
|
1857
|
-
if (trimmedSecondOpinion) {
|
|
1858
|
-
body.provider_override = trimmedSecondOpinion;
|
|
1859
|
-
}
|
|
1860
|
-
const trimmedProvider = provider?.trim();
|
|
1861
|
-
if (trimmedProvider && !trimmedSecondOpinion) {
|
|
1862
|
-
body.provider = trimmedProvider;
|
|
1863
|
-
}
|
|
1864
|
-
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/request-reimplement-context`), {
|
|
1865
|
-
method: "POST",
|
|
1866
|
-
headers: POST_HEADERS,
|
|
1867
|
-
body: JSON.stringify(body),
|
|
1868
|
-
});
|
|
1869
|
-
if (!resp.ok) {
|
|
1870
|
-
const errorText = await handleResponse(resp);
|
|
1871
|
-
return {
|
|
1872
|
-
content: [{ type: "text", text: `Failed to request reimplement context: ${errorText}` }],
|
|
1873
|
-
};
|
|
1874
|
-
}
|
|
1875
|
-
if (wait_for_result) {
|
|
1876
|
-
const getUrl = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/reimplement-context`, { repo_name: REPO_NAME });
|
|
1877
|
-
const result = await pollForResult(getUrl, 900_000, `Reimplement context for ${ticket_number}`);
|
|
1878
|
-
if (!result.ok) {
|
|
1879
|
-
return { content: [{ type: "text", text: result.text }] };
|
|
1880
|
-
}
|
|
1881
|
-
let text = result.text;
|
|
1882
|
-
if (save_locally) {
|
|
1883
|
-
const note = await saveLocally(getDocsPath("reimplementations"), `${ticket_number}-context.md`, text);
|
|
1884
|
-
text += note;
|
|
1885
|
-
}
|
|
1886
|
-
return { content: [{ type: "text", text }] };
|
|
1887
|
-
}
|
|
1888
|
-
const confirmationText = `Reimplement context processing requested for ${ticket_number}. ` +
|
|
1889
|
-
`Processing typically takes 1-2 minutes. ` +
|
|
1890
|
-
`Use get_reimplement_context with ticket_number "${ticket_number}" to retrieve the results once processing completes.`;
|
|
1891
|
-
return { content: [{ type: "text", text: confirmationText }] };
|
|
2508
|
+
}, async (args) => {
|
|
2509
|
+
return requestTicketArtifact("reimplement_context", args);
|
|
1892
2510
|
});
|
|
1893
2511
|
registerTool("get_reimplement_context", {
|
|
1894
|
-
|
|
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. " +
|
|
1895
2520
|
"Returns a markdown document with new/changed information diffed against stored state, " +
|
|
1896
2521
|
"the original ticket description, and the existing implementation plan. " +
|
|
1897
|
-
"Returns 404
|
|
1898
|
-
"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.",
|
|
1899
2523
|
inputSchema: {
|
|
1900
2524
|
ticket_number: z
|
|
1901
2525
|
.string()
|
|
@@ -1906,35 +2530,24 @@ registerTool("get_reimplement_context", {
|
|
|
1906
2530
|
.default(true)
|
|
1907
2531
|
.describe("Save the context to BAPI_DOCS_DIR/reimplementations/{ticket_number}-context.md. Defaults to true."),
|
|
1908
2532
|
},
|
|
1909
|
-
}, async (
|
|
1910
|
-
|
|
1911
|
-
if (resp.status === 404) {
|
|
1912
|
-
return {
|
|
1913
|
-
content: [{
|
|
1914
|
-
type: "text",
|
|
1915
|
-
text: JSON.stringify({
|
|
1916
|
-
error: "NOT_FOUND",
|
|
1917
|
-
message: `Reimplement context for ${ticket_number} is not yet available. ` +
|
|
1918
|
-
`Processing may still be in progress. Try again in a moment, ` +
|
|
1919
|
-
`or call request_reimplement_context to trigger processing.`,
|
|
1920
|
-
}),
|
|
1921
|
-
}],
|
|
1922
|
-
};
|
|
1923
|
-
}
|
|
1924
|
-
const text = await handleResponse(resp);
|
|
1925
|
-
if (save_locally) {
|
|
1926
|
-
const note = await saveLocally(getDocsPath("reimplementations"), `${ticket_number}-context.md`, text);
|
|
1927
|
-
return { content: [{ type: "text", text: text + note }] };
|
|
1928
|
-
}
|
|
1929
|
-
return { content: [{ type: "text", text }] };
|
|
2533
|
+
}, async (args) => {
|
|
2534
|
+
return getTicketArtifact("reimplement_context", args);
|
|
1930
2535
|
});
|
|
1931
2536
|
// ---------------------------------------------------------------------------
|
|
1932
2537
|
// Ticket Lifecycle Tracking
|
|
1933
2538
|
// ---------------------------------------------------------------------------
|
|
1934
2539
|
registerTool("track_ticket", {
|
|
1935
|
-
|
|
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. " +
|
|
1936
2548
|
"If the ticket is already tracked, this is a safe no-op — it upserts the description and repo_name without error. " +
|
|
1937
|
-
"
|
|
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. " +
|
|
1938
2551
|
"The repo_name is automatically injected from the configured environment.",
|
|
1939
2552
|
inputSchema: {
|
|
1940
2553
|
ticket_number: z.string().describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123)"),
|
|
@@ -1946,13 +2559,19 @@ registerTool("track_ticket", {
|
|
|
1946
2559
|
payload.description = description;
|
|
1947
2560
|
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/track`), {
|
|
1948
2561
|
method: "POST",
|
|
1949
|
-
headers:
|
|
2562
|
+
headers: await getPostHeaders(),
|
|
1950
2563
|
body: JSON.stringify(payload),
|
|
1951
2564
|
});
|
|
1952
2565
|
const text = await handleResponse(resp);
|
|
1953
2566
|
return { content: [{ type: "text", text }] };
|
|
1954
2567
|
});
|
|
1955
2568
|
registerTool("update_ticket_state", {
|
|
2569
|
+
annotations: {
|
|
2570
|
+
readOnlyHint: false,
|
|
2571
|
+
destructiveHint: false,
|
|
2572
|
+
idempotentHint: false,
|
|
2573
|
+
openWorldHint: true,
|
|
2574
|
+
},
|
|
1956
2575
|
description: "Update workflow state timestamps on a tracked ticket. Each specified field is set to the current UTC timestamp on the server. " +
|
|
1957
2576
|
"Valid field names: 'critique_called', 'critique_answered', 'clarify_called', 'clarify_answered', 'plan_generated', 'implemented', 'reimplement_called'. " +
|
|
1958
2577
|
"The ticket must already be tracked (via track_ticket) or a 404 error is returned. " +
|
|
@@ -1966,13 +2585,19 @@ registerTool("update_ticket_state", {
|
|
|
1966
2585
|
const payload = { repo_name: REPO_NAME, fields };
|
|
1967
2586
|
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/state`), {
|
|
1968
2587
|
method: "PUT",
|
|
1969
|
-
headers:
|
|
2588
|
+
headers: await getPostHeaders(),
|
|
1970
2589
|
body: JSON.stringify(payload),
|
|
1971
2590
|
});
|
|
1972
2591
|
const text = await handleResponse(resp);
|
|
1973
2592
|
return { content: [{ type: "text", text }] };
|
|
1974
2593
|
});
|
|
1975
2594
|
registerTool("get_ticket_state", {
|
|
2595
|
+
annotations: {
|
|
2596
|
+
readOnlyHint: true,
|
|
2597
|
+
destructiveHint: false,
|
|
2598
|
+
idempotentHint: true,
|
|
2599
|
+
openWorldHint: true,
|
|
2600
|
+
},
|
|
1976
2601
|
description: "Retrieve workflow state timestamps and artifact existence flags for a tracked ticket. " +
|
|
1977
2602
|
"Returns timestamps for each state field (critique_called, critique_answered, clarify_called, clarify_answered, plan_generated, implemented, reimplement_called) " +
|
|
1978
2603
|
"and boolean flags indicating whether artifacts exist (has_clarifying_questions, has_critique, has_plan). " +
|
|
@@ -1983,7 +2608,7 @@ registerTool("get_ticket_state", {
|
|
|
1983
2608
|
},
|
|
1984
2609
|
}, async ({ ticket_number }) => {
|
|
1985
2610
|
const url = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/state`, { repo_name: REPO_NAME });
|
|
1986
|
-
const resp = await fetch(url, { headers:
|
|
2611
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
1987
2612
|
const text = await handleResponse(resp);
|
|
1988
2613
|
return { content: [{ type: "text", text }] };
|
|
1989
2614
|
});
|
|
@@ -1991,6 +2616,12 @@ registerTool("get_ticket_state", {
|
|
|
1991
2616
|
// Jira Transitions
|
|
1992
2617
|
// ---------------------------------------------------------------------------
|
|
1993
2618
|
registerTool("get_jira_transitions", {
|
|
2619
|
+
annotations: {
|
|
2620
|
+
readOnlyHint: true,
|
|
2621
|
+
destructiveHint: false,
|
|
2622
|
+
idempotentHint: true,
|
|
2623
|
+
openWorldHint: true,
|
|
2624
|
+
},
|
|
1994
2625
|
description: "List available Jira workflow transitions for a ticket. Returns each transition's id, name, and target status. " +
|
|
1995
2626
|
"Use this to discover what status changes are possible for a given ticket. " +
|
|
1996
2627
|
"The repo_name is automatically injected from the configured environment.",
|
|
@@ -1999,11 +2630,17 @@ registerTool("get_jira_transitions", {
|
|
|
1999
2630
|
},
|
|
2000
2631
|
}, async ({ ticket_number }) => {
|
|
2001
2632
|
const url = buildGetUrl(`/tickets/${encodeURIComponent(ticket_number)}/jira-transitions`, { repo_name: REPO_NAME });
|
|
2002
|
-
const resp = await fetch(url, { headers:
|
|
2633
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
2003
2634
|
const text = await handleResponse(resp);
|
|
2004
2635
|
return { content: [{ type: "text", text }] };
|
|
2005
2636
|
});
|
|
2006
2637
|
registerTool("update_jira_status", {
|
|
2638
|
+
annotations: {
|
|
2639
|
+
readOnlyHint: false,
|
|
2640
|
+
destructiveHint: true,
|
|
2641
|
+
idempotentHint: true,
|
|
2642
|
+
openWorldHint: true,
|
|
2643
|
+
},
|
|
2007
2644
|
description: "Transition a Jira ticket to a specified target status by executing a workflow transition. " +
|
|
2008
2645
|
"Provide either target_status (matched case-insensitively against available transitions) or transition_id (used directly). " +
|
|
2009
2646
|
"If transition_id is provided, it takes precedence over target_status. " +
|
|
@@ -2024,16 +2661,24 @@ registerTool("update_jira_status", {
|
|
|
2024
2661
|
payload.transition_id = transition_id;
|
|
2025
2662
|
const resp = await fetch(buildUrl(`/tickets/${encodeURIComponent(ticket_number)}/jira-status`), {
|
|
2026
2663
|
method: "PUT",
|
|
2027
|
-
headers:
|
|
2664
|
+
headers: await getPostHeaders(),
|
|
2028
2665
|
body: JSON.stringify(payload),
|
|
2029
2666
|
});
|
|
2030
2667
|
const text = await handleResponse(resp);
|
|
2031
2668
|
return { content: [{ type: "text", text }] };
|
|
2032
2669
|
});
|
|
2033
2670
|
registerTool("resolve_target_status", {
|
|
2034
|
-
|
|
2035
|
-
|
|
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.' " +
|
|
2036
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. " +
|
|
2037
2682
|
"Requires a ticket_number to fetch available transitions from Jira. " +
|
|
2038
2683
|
"The repo_name is automatically injected from the configured environment.",
|
|
2039
2684
|
inputSchema: {
|
|
@@ -2049,7 +2694,7 @@ registerTool("resolve_target_status", {
|
|
|
2049
2694
|
payload.force_rerun = force_rerun;
|
|
2050
2695
|
const resp = await fetch(buildUrl("/resolve-target-status"), {
|
|
2051
2696
|
method: "POST",
|
|
2052
|
-
headers:
|
|
2697
|
+
headers: await getPostHeaders(),
|
|
2053
2698
|
body: JSON.stringify(payload),
|
|
2054
2699
|
});
|
|
2055
2700
|
const text = await handleResponse(resp);
|
|
@@ -2069,8 +2714,15 @@ const VALID_CONFIG_FIELDS = [
|
|
|
2069
2714
|
"allow_mutating_smoke_ops",
|
|
2070
2715
|
"selected_mcp_slugs",
|
|
2071
2716
|
"base_branch",
|
|
2717
|
+
"tiered_execution",
|
|
2072
2718
|
].join(", ");
|
|
2073
2719
|
registerTool("list_config_fields", {
|
|
2720
|
+
annotations: {
|
|
2721
|
+
readOnlyHint: true,
|
|
2722
|
+
destructiveHint: false,
|
|
2723
|
+
idempotentHint: true,
|
|
2724
|
+
openWorldHint: true,
|
|
2725
|
+
},
|
|
2074
2726
|
description: "List all configurable fields available for reading and updating via the Bridge API. " +
|
|
2075
2727
|
"Returns each field's name and a description of its purpose. No database values are returned — " +
|
|
2076
2728
|
"use get_config_field to read a specific field's current value. " +
|
|
@@ -2078,11 +2730,17 @@ registerTool("list_config_fields", {
|
|
|
2078
2730
|
inputSchema: {},
|
|
2079
2731
|
}, async () => {
|
|
2080
2732
|
const url = buildGetUrl("/config-fields", { repo_name: REPO_NAME });
|
|
2081
|
-
const resp = await fetch(url, { headers:
|
|
2733
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
2082
2734
|
const text = await handleResponse(resp);
|
|
2083
2735
|
return { content: [{ type: "text", text }] };
|
|
2084
2736
|
});
|
|
2085
2737
|
registerTool("get_my_role", {
|
|
2738
|
+
annotations: {
|
|
2739
|
+
readOnlyHint: true,
|
|
2740
|
+
destructiveHint: false,
|
|
2741
|
+
idempotentHint: true,
|
|
2742
|
+
openWorldHint: true,
|
|
2743
|
+
},
|
|
2086
2744
|
description: "Check the role and auth source for the current API key. " +
|
|
2087
2745
|
"Returns JSON with {role: \"admin\" | \"member\" | null, source: \"user_access\" | \"legacy\"}. " +
|
|
2088
2746
|
"Use this to check if the current key has admin permissions before attempting configuration updates " +
|
|
@@ -2090,11 +2748,17 @@ registerTool("get_my_role", {
|
|
|
2090
2748
|
inputSchema: {},
|
|
2091
2749
|
}, async () => {
|
|
2092
2750
|
const url = buildGetUrl("/my-role", { repo_name: REPO_NAME });
|
|
2093
|
-
const resp = await fetch(url, { headers:
|
|
2751
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
2094
2752
|
const text = await handleResponse(resp);
|
|
2095
2753
|
return { content: [{ type: "text", text }] };
|
|
2096
2754
|
});
|
|
2097
2755
|
registerTool("get_config_field", {
|
|
2756
|
+
annotations: {
|
|
2757
|
+
readOnlyHint: true,
|
|
2758
|
+
destructiveHint: false,
|
|
2759
|
+
idempotentHint: true,
|
|
2760
|
+
openWorldHint: true,
|
|
2761
|
+
},
|
|
2098
2762
|
description: "Read the current value and metadata for a specific configuration field. " +
|
|
2099
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. " +
|
|
2100
2764
|
"Use this before update_config_field to understand the current state and build upon it rather than overwriting blindly.",
|
|
@@ -2103,11 +2767,17 @@ registerTool("get_config_field", {
|
|
|
2103
2767
|
},
|
|
2104
2768
|
}, async ({ field_name }) => {
|
|
2105
2769
|
const url = buildGetUrl(`/config-field/${encodeURIComponent(field_name)}`, { repo_name: REPO_NAME });
|
|
2106
|
-
const resp = await fetch(url, { headers:
|
|
2770
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
2107
2771
|
const text = await handleResponse(resp);
|
|
2108
2772
|
return { content: [{ type: "text", text }] };
|
|
2109
2773
|
});
|
|
2110
2774
|
registerTool("update_config_field", {
|
|
2775
|
+
annotations: {
|
|
2776
|
+
readOnlyHint: false,
|
|
2777
|
+
destructiveHint: true,
|
|
2778
|
+
idempotentHint: true,
|
|
2779
|
+
openWorldHint: true,
|
|
2780
|
+
},
|
|
2111
2781
|
description: "Update a specific configuration field in the Bridge database. " +
|
|
2112
2782
|
"These fields control LLM behavior during code review, planning, and documentation. " +
|
|
2113
2783
|
"Always call get_config_field first to read the current value and build upon it. " +
|
|
@@ -2122,6 +2792,8 @@ registerTool("update_config_field", {
|
|
|
2122
2792
|
"The base_branch field is a string/null field controlling the development base branch used by PR " +
|
|
2123
2793
|
"creation (/create-pr) and start-tickets worktree creation; an empty/null value clears it and " +
|
|
2124
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'. " +
|
|
2125
2797
|
"For string fields, omit both value and file_path to set the field to NULL (clearing it). " +
|
|
2126
2798
|
"Scalar boolean fields are NOT NULL and have no clear/null state: omitting the value writes false " +
|
|
2127
2799
|
"(matching the API-layer coercion), so pass true/false explicitly."),
|
|
@@ -2152,7 +2824,7 @@ registerTool("update_config_field", {
|
|
|
2152
2824
|
const arrayValue = value === undefined ? [] : value;
|
|
2153
2825
|
const resp = await fetch(buildUrl(`/config-field/${encodeURIComponent(field_name)}`), {
|
|
2154
2826
|
method: "PUT",
|
|
2155
|
-
headers:
|
|
2827
|
+
headers: await getPostHeaders(),
|
|
2156
2828
|
body: JSON.stringify({ repo_name: REPO_NAME, value: arrayValue }),
|
|
2157
2829
|
});
|
|
2158
2830
|
const text = await handleResponse(resp);
|
|
@@ -2201,7 +2873,7 @@ registerTool("update_config_field", {
|
|
|
2201
2873
|
}
|
|
2202
2874
|
const resp = await fetch(buildUrl(`/config-field/${encodeURIComponent(field_name)}`), {
|
|
2203
2875
|
method: "PUT",
|
|
2204
|
-
headers:
|
|
2876
|
+
headers: await getPostHeaders(),
|
|
2205
2877
|
body: JSON.stringify({ repo_name: REPO_NAME, value: boolValue }),
|
|
2206
2878
|
});
|
|
2207
2879
|
const text = await handleResponse(resp);
|
|
@@ -2219,7 +2891,7 @@ registerTool("update_config_field", {
|
|
|
2219
2891
|
}
|
|
2220
2892
|
const resp = await fetch(buildUrl(`/config-field/${encodeURIComponent(field_name)}`), {
|
|
2221
2893
|
method: "PUT",
|
|
2222
|
-
headers:
|
|
2894
|
+
headers: await getPostHeaders(),
|
|
2223
2895
|
body: JSON.stringify({ repo_name: REPO_NAME, value: finalValue }),
|
|
2224
2896
|
});
|
|
2225
2897
|
const text = await handleResponse(resp);
|
|
@@ -2282,8 +2954,14 @@ function formatDeepResearchStatus(body, taskId) {
|
|
|
2282
2954
|
return `Status: ${body.status}${elapsed}${reason} (task_id: ${taskId}). Try again in a minute.`;
|
|
2283
2955
|
}
|
|
2284
2956
|
registerTool("request_deep_research", {
|
|
2285
|
-
|
|
2286
|
-
|
|
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). " +
|
|
2287
2965
|
"\n\n" +
|
|
2288
2966
|
"WHEN TO USE: Use this tool when you need to investigate unfamiliar technologies, " +
|
|
2289
2967
|
"gather implementation guidance across multiple sources, research best practices for a complex topic, " +
|
|
@@ -2325,7 +3003,7 @@ registerTool("request_deep_research", {
|
|
|
2325
3003
|
submitPayload.ticket_number = ticket_number;
|
|
2326
3004
|
const submitResp = await fetch(buildUrl("/deep-research"), {
|
|
2327
3005
|
method: "POST",
|
|
2328
|
-
headers:
|
|
3006
|
+
headers: await getPostHeaders(),
|
|
2329
3007
|
body: JSON.stringify(submitPayload),
|
|
2330
3008
|
});
|
|
2331
3009
|
if (!submitResp.ok) {
|
|
@@ -2351,7 +3029,7 @@ registerTool("request_deep_research", {
|
|
|
2351
3029
|
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
2352
3030
|
console.error(`Deep research in progress... (elapsed: ${elapsed}s, status: ${lastStatus})`);
|
|
2353
3031
|
const statusUrl = buildGetUrl(`/deep-research/${taskId}/status`, { repo_name: REPO_NAME });
|
|
2354
|
-
const statusResp = await fetch(statusUrl, { headers:
|
|
3032
|
+
const statusResp = await fetch(statusUrl, { headers: await getGetHeaders() });
|
|
2355
3033
|
if (!statusResp.ok) {
|
|
2356
3034
|
const errorText = await handleResponse(statusResp);
|
|
2357
3035
|
return { content: [{ type: "text", text: JSON.stringify({ error: "INTERNAL_ERROR", status: 500, message: `Error polling deep research status: ${errorText}` }) }] };
|
|
@@ -2388,7 +3066,7 @@ registerTool("request_deep_research", {
|
|
|
2388
3066
|
}
|
|
2389
3067
|
// 3. Retrieve the result
|
|
2390
3068
|
const resultUrl = buildGetUrl(`/deep-research/${taskId}/result`, { repo_name: REPO_NAME });
|
|
2391
|
-
const resultResp = await fetch(resultUrl, { headers:
|
|
3069
|
+
const resultResp = await fetch(resultUrl, { headers: await getGetHeaders() });
|
|
2392
3070
|
if (!resultResp.ok) {
|
|
2393
3071
|
const errorText = await handleResponse(resultResp);
|
|
2394
3072
|
return { content: [{ type: "text", text: JSON.stringify({ error: "INTERNAL_ERROR", status: 500, message: `Error retrieving deep research result: ${errorText}` }) }] };
|
|
@@ -2397,15 +3075,22 @@ registerTool("request_deep_research", {
|
|
|
2397
3075
|
// 4. Optionally save to file
|
|
2398
3076
|
if (save_locally) {
|
|
2399
3077
|
const slug = slugify(query);
|
|
2400
|
-
const note = await saveLocally(getDocsPath("deep-research"), `${slug}-${taskId}.md`, resultText);
|
|
3078
|
+
const note = await saveLocally(await getDocsPath("deep-research"), `${slug}-${taskId}.md`, resultText);
|
|
2401
3079
|
resultText += note;
|
|
2402
3080
|
}
|
|
2403
3081
|
return { content: [{ type: "text", text: resultText }] };
|
|
2404
3082
|
});
|
|
2405
3083
|
registerTool("get_deep_research", {
|
|
2406
|
-
|
|
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. " +
|
|
2407
3092
|
"Returns the full markdown research report if the task is completed, " +
|
|
2408
|
-
"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. " +
|
|
2409
3094
|
"Use this after calling request_deep_research with wait_for_result=false.",
|
|
2410
3095
|
inputSchema: {
|
|
2411
3096
|
task_id: z.number().describe("The task ID returned by request_deep_research."),
|
|
@@ -2416,7 +3101,7 @@ registerTool("get_deep_research", {
|
|
|
2416
3101
|
}, async ({ task_id, query_slug, save_locally }) => {
|
|
2417
3102
|
// 1. Check status
|
|
2418
3103
|
const statusUrl = buildGetUrl(`/deep-research/${task_id}/status`, { repo_name: REPO_NAME });
|
|
2419
|
-
const statusResp = await fetch(statusUrl, { headers:
|
|
3104
|
+
const statusResp = await fetch(statusUrl, { headers: await getGetHeaders() });
|
|
2420
3105
|
if (!statusResp.ok) {
|
|
2421
3106
|
const errorText = await handleResponse(statusResp);
|
|
2422
3107
|
return { content: [{ type: "text", text: errorText }] };
|
|
@@ -2440,7 +3125,7 @@ registerTool("get_deep_research", {
|
|
|
2440
3125
|
}
|
|
2441
3126
|
// 2. Retrieve the result
|
|
2442
3127
|
const resultUrl = buildGetUrl(`/deep-research/${task_id}/result`, { repo_name: REPO_NAME });
|
|
2443
|
-
const resultResp = await fetch(resultUrl, { headers:
|
|
3128
|
+
const resultResp = await fetch(resultUrl, { headers: await getGetHeaders() });
|
|
2444
3129
|
if (!resultResp.ok) {
|
|
2445
3130
|
const errorText = await handleResponse(resultResp);
|
|
2446
3131
|
return { content: [{ type: "text", text: errorText }] };
|
|
@@ -2449,11 +3134,15 @@ registerTool("get_deep_research", {
|
|
|
2449
3134
|
// 3. Optionally save to file
|
|
2450
3135
|
if (save_locally) {
|
|
2451
3136
|
const slug = query_slug || "research";
|
|
2452
|
-
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);
|
|
2453
3138
|
resultText += note;
|
|
2454
3139
|
}
|
|
2455
3140
|
return { content: [{ type: "text", text: resultText }] };
|
|
2456
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).
|
|
2457
3146
|
const BRAINSTORM_TERMINAL_STATUSES = new Set([
|
|
2458
3147
|
"completed",
|
|
2459
3148
|
"failed",
|
|
@@ -2462,16 +3151,6 @@ const BRAINSTORM_TERMINAL_STATUSES = new Set([
|
|
|
2462
3151
|
function isBrainstormTerminalStatus(status) {
|
|
2463
3152
|
return BRAINSTORM_TERMINAL_STATUSES.has(status);
|
|
2464
3153
|
}
|
|
2465
|
-
function sanitizeProviderForFilename(provider) {
|
|
2466
|
-
// Defensive: allow letters/digits/hyphen/underscore only; collapse runs and
|
|
2467
|
-
// trim trailing punctuation. Falls back to "provider" for an empty result.
|
|
2468
|
-
const cleaned = provider
|
|
2469
|
-
.toLowerCase()
|
|
2470
|
-
.replace(/[^a-z0-9_-]+/g, "-")
|
|
2471
|
-
.replace(/-+/g, "-")
|
|
2472
|
-
.replace(/^-+|-+$/g, "");
|
|
2473
|
-
return cleaned || "provider";
|
|
2474
|
-
}
|
|
2475
3154
|
async function pollBrainstormUntilTerminal(brainstormId, repoName) {
|
|
2476
3155
|
const startTime = Date.now();
|
|
2477
3156
|
const MAX_TIMEOUT_MS = 15 * 60 * 1000;
|
|
@@ -2480,7 +3159,7 @@ async function pollBrainstormUntilTerminal(brainstormId, repoName) {
|
|
|
2480
3159
|
while (Date.now() - startTime < MAX_TIMEOUT_MS) {
|
|
2481
3160
|
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
2482
3161
|
const statusUrl = buildGetUrl(`/brainstorms/${brainstormId}/status`, { repo_name: repoName });
|
|
2483
|
-
const statusResp = await fetch(statusUrl, { headers:
|
|
3162
|
+
const statusResp = await fetch(statusUrl, { headers: await getGetHeaders() });
|
|
2484
3163
|
if (!statusResp.ok) {
|
|
2485
3164
|
return latest;
|
|
2486
3165
|
}
|
|
@@ -2495,27 +3174,13 @@ async function pollBrainstormUntilTerminal(brainstormId, repoName) {
|
|
|
2495
3174
|
}
|
|
2496
3175
|
return latest;
|
|
2497
3176
|
}
|
|
2498
|
-
async function saveBrainstormResultsLocally(envelope) {
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
}
|
|
2506
|
-
const providerSegment = sanitizeProviderForFilename(row.provider);
|
|
2507
|
-
const filename = `${envelope.brainstorm_id}-${providerSegment}.md`;
|
|
2508
|
-
const filePath = path.join(dir, filename);
|
|
2509
|
-
try {
|
|
2510
|
-
await mkdir(dir, { recursive: true });
|
|
2511
|
-
await writeFile(filePath, markdown, "utf-8");
|
|
2512
|
-
savedPaths.push(filePath);
|
|
2513
|
-
}
|
|
2514
|
-
catch {
|
|
2515
|
-
// Skip rows that fail to write — never block the response.
|
|
2516
|
-
}
|
|
2517
|
-
}
|
|
2518
|
-
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);
|
|
2519
3184
|
}
|
|
2520
3185
|
function formatBrainstormToolResponse(envelope, savedPaths) {
|
|
2521
3186
|
const lines = [];
|
|
@@ -2544,14 +3209,28 @@ function formatBrainstormToolResponse(envelope, savedPaths) {
|
|
|
2544
3209
|
return lines.join("\n");
|
|
2545
3210
|
}
|
|
2546
3211
|
registerTool("request_brainstorm", {
|
|
2547
|
-
|
|
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 " +
|
|
2548
3219
|
"(default: OpenAI + Gemini) and then runs a synthesizer pass over the completed opinions. " +
|
|
2549
|
-
"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). " +
|
|
2550
3221
|
"\n\n" +
|
|
2551
3222
|
"BEHAVIOR: By default, returns immediately with a brainstorm_id. Set wait_for_result=true " +
|
|
2552
3223
|
"to poll for terminal status (up to 15 minutes), then retrieve and optionally save the result. " +
|
|
2553
|
-
"When save_locally=true (default), each provider's markdown is written
|
|
2554
|
-
"
|
|
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).",
|
|
2555
3234
|
inputSchema: {
|
|
2556
3235
|
task_description: z.string().describe("Free-form description of the task to brainstorm about. Sent verbatim — " +
|
|
2557
3236
|
"this tool does NOT read task_description from a file."),
|
|
@@ -2564,12 +3243,14 @@ registerTool("request_brainstorm", {
|
|
|
2564
3243
|
wait_for_result: z.boolean().optional().describe("When true, polls until every row reaches a terminal status (max 15 minutes), " +
|
|
2565
3244
|
"then returns the full result envelope. When false (default), returns immediately."),
|
|
2566
3245
|
save_locally: z.boolean().optional().describe("When true (default), writes each provider's markdown to BAPI_DOCS_DIR/brainstorm/ " +
|
|
2567
|
-
"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."),
|
|
2568
3248
|
prior_brainstorm_id: z.string().optional().describe("Optional brainstorm_id from an earlier brainstorm to refine. " +
|
|
2569
3249
|
"When provided, the new brainstorm receives the prior brainstorm's " +
|
|
2570
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."),
|
|
2571
3252
|
},
|
|
2572
|
-
}, 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, }) => {
|
|
2573
3254
|
const effectiveRepo = repo_name && repo_name.length > 0 ? repo_name : REPO_NAME;
|
|
2574
3255
|
const effectiveProviders = providers !== undefined ? providers : ["openai", "gemini"];
|
|
2575
3256
|
const shouldWait = wait_for_result === true;
|
|
@@ -2586,9 +3267,11 @@ registerTool("request_brainstorm", {
|
|
|
2586
3267
|
if (prior_brainstorm_id) {
|
|
2587
3268
|
submitPayload.prior_brainstorm_request_id = prior_brainstorm_id;
|
|
2588
3269
|
}
|
|
3270
|
+
if (design)
|
|
3271
|
+
submitPayload.design = true;
|
|
2589
3272
|
const submitResp = await fetch(buildUrl("/brainstorms"), {
|
|
2590
3273
|
method: "POST",
|
|
2591
|
-
headers:
|
|
3274
|
+
headers: await getPostHeaders(),
|
|
2592
3275
|
body: JSON.stringify(submitPayload),
|
|
2593
3276
|
});
|
|
2594
3277
|
if (!submitResp.ok) {
|
|
@@ -2614,7 +3297,7 @@ registerTool("request_brainstorm", {
|
|
|
2614
3297
|
};
|
|
2615
3298
|
}
|
|
2616
3299
|
const resultUrl = buildGetUrl(`/brainstorms/${submitBody.brainstorm_id}/result`, { repo_name: effectiveRepo });
|
|
2617
|
-
const resultResp = await fetch(resultUrl, { headers:
|
|
3300
|
+
const resultResp = await fetch(resultUrl, { headers: await getGetHeaders() });
|
|
2618
3301
|
if (!resultResp.ok) {
|
|
2619
3302
|
const errorText = await handleResponse(resultResp);
|
|
2620
3303
|
return { content: [{ type: "text", text: errorText }] };
|
|
@@ -2622,7 +3305,9 @@ registerTool("request_brainstorm", {
|
|
|
2622
3305
|
const envelope = (await resultResp.json());
|
|
2623
3306
|
let savedPaths = [];
|
|
2624
3307
|
if (shouldSave) {
|
|
2625
|
-
|
|
3308
|
+
// Request-time saves use semantic filenames derived from the in-scope
|
|
3309
|
+
// ``task_description``.
|
|
3310
|
+
savedPaths = await saveBrainstormResultsLocally(envelope, task_description);
|
|
2626
3311
|
}
|
|
2627
3312
|
return {
|
|
2628
3313
|
content: [{
|
|
@@ -2632,21 +3317,33 @@ registerTool("request_brainstorm", {
|
|
|
2632
3317
|
};
|
|
2633
3318
|
});
|
|
2634
3319
|
registerTool("get_brainstorm", {
|
|
2635
|
-
|
|
2636
|
-
|
|
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. " +
|
|
2637
3329
|
"When save_locally=true (default), writes each provider's markdown to " +
|
|
2638
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. " +
|
|
2639
3334
|
"DB-only retrieval — never falls back to Jira attachments.",
|
|
2640
3335
|
inputSchema: {
|
|
2641
3336
|
brainstorm_id: z.string().describe("The brainstorm_id (UUID) returned by request_brainstorm."),
|
|
2642
3337
|
repo_name: z.string().optional().describe("Repository name. Defaults to BAPI_REPO_NAME from the environment."),
|
|
2643
|
-
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."),
|
|
2644
3341
|
},
|
|
2645
3342
|
}, async ({ brainstorm_id, repo_name, save_locally }) => {
|
|
2646
3343
|
const effectiveRepo = repo_name && repo_name.length > 0 ? repo_name : REPO_NAME;
|
|
2647
3344
|
const shouldSave = save_locally !== false;
|
|
2648
3345
|
const resultUrl = buildGetUrl(`/brainstorms/${brainstorm_id}/result`, { repo_name: effectiveRepo });
|
|
2649
|
-
const resultResp = await fetch(resultUrl, { headers:
|
|
3346
|
+
const resultResp = await fetch(resultUrl, { headers: await getGetHeaders() });
|
|
2650
3347
|
if (!resultResp.ok) {
|
|
2651
3348
|
const errorText = await handleResponse(resultResp);
|
|
2652
3349
|
return { content: [{ type: "text", text: errorText }] };
|
|
@@ -2654,6 +3351,9 @@ registerTool("get_brainstorm", {
|
|
|
2654
3351
|
const envelope = (await resultResp.json());
|
|
2655
3352
|
let savedPaths = [];
|
|
2656
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``.)
|
|
2657
3357
|
savedPaths = await saveBrainstormResultsLocally(envelope);
|
|
2658
3358
|
}
|
|
2659
3359
|
return {
|
|
@@ -2667,6 +3367,12 @@ registerTool("get_brainstorm", {
|
|
|
2667
3367
|
// VCS & CI Tools
|
|
2668
3368
|
// ---------------------------------------------------------------------------
|
|
2669
3369
|
registerTool("create_pull_request", {
|
|
3370
|
+
annotations: {
|
|
3371
|
+
readOnlyHint: false,
|
|
3372
|
+
destructiveHint: false,
|
|
3373
|
+
idempotentHint: true,
|
|
3374
|
+
openWorldHint: true,
|
|
3375
|
+
},
|
|
2670
3376
|
description: "Create a pull request on the configured VCS provider (GitHub or Bitbucket). " +
|
|
2671
3377
|
"Returns a structured response with {available, reason, action, detail}. " +
|
|
2672
3378
|
"If a PR already exists for the head branch, returns it with created=false. " +
|
|
@@ -2689,13 +3395,19 @@ registerTool("create_pull_request", {
|
|
|
2689
3395
|
payload.body = body;
|
|
2690
3396
|
const resp = await fetch(buildUrl("/vcs/pull-requests"), {
|
|
2691
3397
|
method: "POST",
|
|
2692
|
-
headers:
|
|
3398
|
+
headers: await getPostHeaders(),
|
|
2693
3399
|
body: JSON.stringify(payload),
|
|
2694
3400
|
});
|
|
2695
3401
|
const text = await handleResponse(resp);
|
|
2696
3402
|
return { content: [{ type: "text", text }] };
|
|
2697
3403
|
});
|
|
2698
3404
|
const resolveCiChecksTool = registerTool("resolve_ci_checks", {
|
|
3405
|
+
annotations: {
|
|
3406
|
+
readOnlyHint: false,
|
|
3407
|
+
destructiveHint: false,
|
|
3408
|
+
idempotentHint: true,
|
|
3409
|
+
openWorldHint: true,
|
|
3410
|
+
},
|
|
2699
3411
|
description: "Discover and classify CI checks for the configured repository. " +
|
|
2700
3412
|
"Queries GitHub Check Runs + Commit Statuses APIs (or Bitbucket Build Statuses), " +
|
|
2701
3413
|
"then uses Branch Protection API or LLM to determine which checks are required for merging. " +
|
|
@@ -2715,7 +3427,7 @@ const resolveCiChecksTool = registerTool("resolve_ci_checks", {
|
|
|
2715
3427
|
payload.force_rerun = force_rerun;
|
|
2716
3428
|
const resp = await fetch(buildUrl("/resolve-ci-checks"), {
|
|
2717
3429
|
method: "POST",
|
|
2718
|
-
headers:
|
|
3430
|
+
headers: await getPostHeaders(),
|
|
2719
3431
|
body: JSON.stringify(payload),
|
|
2720
3432
|
});
|
|
2721
3433
|
const text = await handleResponse(resp);
|
|
@@ -2732,6 +3444,12 @@ const resolveCiChecksTool = registerTool("resolve_ci_checks", {
|
|
|
2732
3444
|
return { content: [{ type: "text", text }] };
|
|
2733
3445
|
});
|
|
2734
3446
|
const pollCiChecksTool = registerTool("poll_ci_checks", {
|
|
3447
|
+
annotations: {
|
|
3448
|
+
readOnlyHint: true,
|
|
3449
|
+
destructiveHint: false,
|
|
3450
|
+
idempotentHint: true,
|
|
3451
|
+
openWorldHint: true,
|
|
3452
|
+
},
|
|
2735
3453
|
description: "Poll the current status of CI checks for a specific commit. " +
|
|
2736
3454
|
"Requires that resolve_ci_checks has been called first to populate the check configuration. " +
|
|
2737
3455
|
"Returns per-check status, all_complete, all_passed, and unknown_checks fields. " +
|
|
@@ -2746,7 +3464,7 @@ const pollCiChecksTool = registerTool("poll_ci_checks", {
|
|
|
2746
3464
|
repo_name: REPO_NAME,
|
|
2747
3465
|
commit_ref,
|
|
2748
3466
|
});
|
|
2749
|
-
const resp = await fetch(url, { headers:
|
|
3467
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
2750
3468
|
const text = await handleResponse(resp);
|
|
2751
3469
|
return { content: [{ type: "text", text }] };
|
|
2752
3470
|
});
|
|
@@ -2756,7 +3474,7 @@ const pollCiChecksTool = registerTool("poll_ci_checks", {
|
|
|
2756
3474
|
async function checkCiConfigAndDisablePoll() {
|
|
2757
3475
|
try {
|
|
2758
3476
|
const url = buildGetUrl("/config-field/ci_check_config", { repo_name: REPO_NAME });
|
|
2759
|
-
const resp = await fetch(url, { headers:
|
|
3477
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
2760
3478
|
if (resp.ok) {
|
|
2761
3479
|
const body = await resp.json();
|
|
2762
3480
|
const value = body.value;
|
|
@@ -2780,42 +3498,45 @@ async function checkCiConfigAndDisablePoll() {
|
|
|
2780
3498
|
// Check config before connecting
|
|
2781
3499
|
await checkCiConfigAndDisablePoll();
|
|
2782
3500
|
// ---------------------------------------------------------------------------
|
|
2783
|
-
// Custom pipeline loading (BAPI-275)
|
|
3501
|
+
// Custom pipeline loading (BAPI-275, deferred in BAPI-338)
|
|
2784
3502
|
// ---------------------------------------------------------------------------
|
|
2785
3503
|
//
|
|
2786
|
-
// Custom user pipelines
|
|
2787
|
-
//
|
|
2788
|
-
//
|
|
2789
|
-
//
|
|
2790
|
-
//
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
const customResult = await loadCustomPipelines(BAPI_PIPELINES_DIR, instructionsDir, BUNDLED_INSTRUCTIONS);
|
|
2794
|
-
for (const [key, pipeline] of Object.entries(customResult.pipelines)) {
|
|
2795
|
-
if (key in BUNDLED_PIPELINES) {
|
|
2796
|
-
console.error(`Warning: user pipeline "${key}" overrides bundled pipeline.`);
|
|
2797
|
-
}
|
|
2798
|
-
PIPELINES[key] = pipeline;
|
|
2799
|
-
}
|
|
2800
|
-
Object.assign(INSTRUCTIONS, customResult.instructions);
|
|
2801
|
-
userPipelineKeys = customResult.userPipelineKeys;
|
|
2802
|
-
}
|
|
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.
|
|
2803
3511
|
// ---------------------------------------------------------------------------
|
|
2804
3512
|
// Pipeline Recipe Tools
|
|
2805
3513
|
// ---------------------------------------------------------------------------
|
|
2806
3514
|
registerTool("get_docs_dir", {
|
|
3515
|
+
annotations: {
|
|
3516
|
+
readOnlyHint: true,
|
|
3517
|
+
destructiveHint: false,
|
|
3518
|
+
idempotentHint: true,
|
|
3519
|
+
openWorldHint: false,
|
|
3520
|
+
},
|
|
2807
3521
|
description: "Return the locally configured docs directory path (BAPI_DOCS_DIR, default docs/tmp). " +
|
|
2808
3522
|
"No parameters. Use this instead of reading the BAPI_DOCS_DIR environment variable directly, " +
|
|
2809
3523
|
"which requires shell access and may be blocked on some AI coding platforms.",
|
|
2810
3524
|
inputSchema: {},
|
|
2811
3525
|
}, async () => {
|
|
2812
|
-
return { content: [{ type: "text", text:
|
|
3526
|
+
return { content: [{ type: "text", text: await getDocsDir() }] };
|
|
2813
3527
|
});
|
|
2814
3528
|
registerTool("list_pipelines", {
|
|
3529
|
+
annotations: {
|
|
3530
|
+
readOnlyHint: true,
|
|
3531
|
+
destructiveHint: false,
|
|
3532
|
+
idempotentHint: true,
|
|
3533
|
+
openWorldHint: false,
|
|
3534
|
+
},
|
|
2815
3535
|
description: "List all available pipeline recipes with their names, descriptions, and required variables. " +
|
|
2816
3536
|
"No parameters. Use this to discover available pipelines before calling get_pipeline_recipe.",
|
|
2817
3537
|
inputSchema: {},
|
|
2818
3538
|
}, async () => {
|
|
3539
|
+
await ensureCustomPipelinesLoaded();
|
|
2819
3540
|
const list = Object.entries(PIPELINES).map(([key, pipeline]) => ({
|
|
2820
3541
|
name: key,
|
|
2821
3542
|
description: pipeline.description ?? "",
|
|
@@ -2827,6 +3548,12 @@ registerTool("list_pipelines", {
|
|
|
2827
3548
|
};
|
|
2828
3549
|
});
|
|
2829
3550
|
registerTool("get_pipeline_recipe", {
|
|
3551
|
+
annotations: {
|
|
3552
|
+
readOnlyHint: true,
|
|
3553
|
+
destructiveHint: false,
|
|
3554
|
+
idempotentHint: true,
|
|
3555
|
+
openWorldHint: false,
|
|
3556
|
+
},
|
|
2830
3557
|
description: "Retrieve a fully resolved pipeline recipe by name. Substitutes variables, resolves instruction " +
|
|
2831
3558
|
"file references to inline content, and returns an ordered array of executable steps. " +
|
|
2832
3559
|
"Each step is either an mcp_call (with tool name and params) or an agent_task (with instruction text). " +
|
|
@@ -2854,6 +3581,7 @@ registerTool("get_pipeline_recipe", {
|
|
|
2854
3581
|
"not via the variables map."),
|
|
2855
3582
|
},
|
|
2856
3583
|
}, async ({ pipeline: pipelineName, variables, skip_steps, auto_approve }) => {
|
|
3584
|
+
await ensureCustomPipelinesLoaded();
|
|
2857
3585
|
const pipelineDef = PIPELINES[pipelineName];
|
|
2858
3586
|
if (!pipelineDef) {
|
|
2859
3587
|
const available = Object.keys(PIPELINES).join(", ");
|
|
@@ -2882,7 +3610,7 @@ registerTool("get_pipeline_recipe", {
|
|
|
2882
3610
|
}
|
|
2883
3611
|
try {
|
|
2884
3612
|
const mergedVariables = {
|
|
2885
|
-
docs_dir:
|
|
3613
|
+
docs_dir: await getDocsDir(),
|
|
2886
3614
|
provider: "",
|
|
2887
3615
|
second_opinion: "",
|
|
2888
3616
|
auto_approve: auto_approve ? "true" : "",
|
|
@@ -2925,21 +3653,13 @@ registerTool("get_pipeline_recipe", {
|
|
|
2925
3653
|
// REPO_MISMATCH | TOOL_ERROR). Pipeline state is persisted server-side; the
|
|
2926
3654
|
// idle TTL defaults to 24 hours and is auto-extended on every state
|
|
2927
3655
|
// transition.
|
|
2928
|
-
function
|
|
2929
|
-
|
|
2930
|
-
for (const [key, pipeline] of Object.entries(PIPELINES)) {
|
|
2931
|
-
const source = userPipelineKeys.has(key) ? " (user)" : "";
|
|
2932
|
-
const desc = pipeline.description ?? "";
|
|
2933
|
-
lines.push(`- ${key}${source} — ${desc}`);
|
|
2934
|
-
}
|
|
2935
|
-
return lines.join("\n");
|
|
2936
|
-
}
|
|
2937
|
-
function buildPipelineOrchestratorDeps() {
|
|
3656
|
+
async function buildPipelineOrchestratorDeps() {
|
|
3657
|
+
await ensureCustomPipelinesLoaded();
|
|
2938
3658
|
return {
|
|
2939
3659
|
baseUrl: BASE_URL,
|
|
2940
|
-
apiKey:
|
|
3660
|
+
apiKey: await getResolvedApiKey(),
|
|
2941
3661
|
repoName: REPO_NAME,
|
|
2942
|
-
docsDir:
|
|
3662
|
+
docsDir: await getDocsDir(),
|
|
2943
3663
|
pipelines: PIPELINES,
|
|
2944
3664
|
instructions: INSTRUCTIONS,
|
|
2945
3665
|
toolHandlers: TOOL_HANDLERS,
|
|
@@ -2948,12 +3668,13 @@ function buildPipelineOrchestratorDeps() {
|
|
|
2948
3668
|
// BAPI-326: dependency injection for the full-automation chain orchestrator.
|
|
2949
3669
|
// Same shape as the pipeline deps plus the bundled chain-recipe registry. The
|
|
2950
3670
|
// chain orchestrator never imports index.ts; everything flows through here.
|
|
2951
|
-
function buildChainOrchestratorDeps() {
|
|
3671
|
+
async function buildChainOrchestratorDeps() {
|
|
3672
|
+
await ensureCustomPipelinesLoaded();
|
|
2952
3673
|
return {
|
|
2953
3674
|
baseUrl: BASE_URL,
|
|
2954
|
-
apiKey:
|
|
3675
|
+
apiKey: await getResolvedApiKey(),
|
|
2955
3676
|
repoName: REPO_NAME,
|
|
2956
|
-
docsDir:
|
|
3677
|
+
docsDir: await getDocsDir(),
|
|
2957
3678
|
pipelines: PIPELINES,
|
|
2958
3679
|
chainRecipes: CHAIN_RECIPES,
|
|
2959
3680
|
instructions: INSTRUCTIONS,
|
|
@@ -2961,6 +3682,12 @@ function buildChainOrchestratorDeps() {
|
|
|
2961
3682
|
};
|
|
2962
3683
|
}
|
|
2963
3684
|
registerTool("run_pipeline", {
|
|
3685
|
+
annotations: {
|
|
3686
|
+
readOnlyHint: false,
|
|
3687
|
+
destructiveHint: false,
|
|
3688
|
+
idempotentHint: false,
|
|
3689
|
+
openWorldHint: true,
|
|
3690
|
+
},
|
|
2964
3691
|
description: "Execute a Bridge API pipeline by name. The orchestrator runs steps sequentially, " +
|
|
2965
3692
|
"dispatching mcp_call steps in-process and pausing on agent_task steps with a " +
|
|
2966
3693
|
"needs_agent_task envelope. Returns a unified envelope keyed on `status`: " +
|
|
@@ -2970,8 +3697,8 @@ registerTool("run_pipeline", {
|
|
|
2970
3697
|
"VALIDATION | NOT_FOUND | EXPIRED | REPO_MISMATCH | TOOL_ERROR). " +
|
|
2971
3698
|
"Paused runs auto-expire after an idle TTL (default 24 hours; override with " +
|
|
2972
3699
|
"`ttl_seconds`). The TTL is reset on every state transition.\n\n" +
|
|
2973
|
-
"
|
|
2974
|
-
|
|
3700
|
+
"Call list_pipelines for the current resolved catalog of available pipeline " +
|
|
3701
|
+
"names (bundled plus any custom user pipelines).",
|
|
2975
3702
|
inputSchema: {
|
|
2976
3703
|
pipeline: z
|
|
2977
3704
|
.string()
|
|
@@ -2996,7 +3723,7 @@ registerTool("run_pipeline", {
|
|
|
2996
3723
|
.describe("Override the default 24-hour idle TTL for this run. Must be a positive integer."),
|
|
2997
3724
|
},
|
|
2998
3725
|
}, async (input) => {
|
|
2999
|
-
const result = await runPipeline(buildPipelineOrchestratorDeps(), input);
|
|
3726
|
+
const result = await runPipeline(await buildPipelineOrchestratorDeps(), input);
|
|
3000
3727
|
return {
|
|
3001
3728
|
content: [
|
|
3002
3729
|
{ type: "text", text: JSON.stringify(result, null, 2) },
|
|
@@ -3004,6 +3731,12 @@ registerTool("run_pipeline", {
|
|
|
3004
3731
|
};
|
|
3005
3732
|
});
|
|
3006
3733
|
registerTool("resume_pipeline", {
|
|
3734
|
+
annotations: {
|
|
3735
|
+
readOnlyHint: false,
|
|
3736
|
+
destructiveHint: false,
|
|
3737
|
+
idempotentHint: false,
|
|
3738
|
+
openWorldHint: true,
|
|
3739
|
+
},
|
|
3007
3740
|
description: "Resume a paused pipeline run with the result of the agent_task. Provide the " +
|
|
3008
3741
|
"`pipeline_run_id` returned by the prior needs_agent_task envelope, and the string " +
|
|
3009
3742
|
"the instruction's `## Return` section asked you to produce as `agent_result`. " +
|
|
@@ -3019,7 +3752,7 @@ registerTool("resume_pipeline", {
|
|
|
3019
3752
|
.describe("The string the paused instruction's ## Return section asked you to produce"),
|
|
3020
3753
|
},
|
|
3021
3754
|
}, async (input) => {
|
|
3022
|
-
const result = await resumePipeline(buildPipelineOrchestratorDeps(), input);
|
|
3755
|
+
const result = await resumePipeline(await buildPipelineOrchestratorDeps(), input);
|
|
3023
3756
|
return {
|
|
3024
3757
|
content: [
|
|
3025
3758
|
{ type: "text", text: JSON.stringify(result, null, 2) },
|
|
@@ -3027,6 +3760,12 @@ registerTool("resume_pipeline", {
|
|
|
3027
3760
|
};
|
|
3028
3761
|
});
|
|
3029
3762
|
registerTool("list_pipeline_runs", {
|
|
3763
|
+
annotations: {
|
|
3764
|
+
readOnlyHint: true,
|
|
3765
|
+
destructiveHint: false,
|
|
3766
|
+
idempotentHint: true,
|
|
3767
|
+
openWorldHint: true,
|
|
3768
|
+
},
|
|
3030
3769
|
description: "List recent pipeline runs for the configured repository, newest first. Returns " +
|
|
3031
3770
|
"metadata only — `resolved_recipe`, resolved params, instruction text, results, " +
|
|
3032
3771
|
"and agent outputs are intentionally excluded. Use this to recover a " +
|
|
@@ -3040,7 +3779,7 @@ registerTool("list_pipeline_runs", {
|
|
|
3040
3779
|
.describe("Optional status filter"),
|
|
3041
3780
|
},
|
|
3042
3781
|
}, async (input) => {
|
|
3043
|
-
const result = await listPipelineRuns(buildPipelineOrchestratorDeps(), input);
|
|
3782
|
+
const result = await listPipelineRuns(await buildPipelineOrchestratorDeps(), input);
|
|
3044
3783
|
return {
|
|
3045
3784
|
content: [
|
|
3046
3785
|
{ type: "text", text: JSON.stringify(result, null, 2) },
|
|
@@ -3048,6 +3787,12 @@ registerTool("list_pipeline_runs", {
|
|
|
3048
3787
|
};
|
|
3049
3788
|
});
|
|
3050
3789
|
registerTool("delete_pipeline_run", {
|
|
3790
|
+
annotations: {
|
|
3791
|
+
readOnlyHint: false,
|
|
3792
|
+
destructiveHint: true,
|
|
3793
|
+
idempotentHint: true,
|
|
3794
|
+
openWorldHint: true,
|
|
3795
|
+
},
|
|
3051
3796
|
description: "Delete a pipeline run row (any status). Use this to discard orphaned `running` " +
|
|
3052
3797
|
"rows from a previous session that can't be resumed (resume_pipeline only accepts " +
|
|
3053
3798
|
"`paused`), to clean up after a failed run, or to remove a no-longer-needed paused " +
|
|
@@ -3061,7 +3806,7 @@ registerTool("delete_pipeline_run", {
|
|
|
3061
3806
|
.describe("UUID of the pipeline run to delete."),
|
|
3062
3807
|
},
|
|
3063
3808
|
}, async (input) => {
|
|
3064
|
-
const result = await deletePipelineRun(buildPipelineOrchestratorDeps(), input);
|
|
3809
|
+
const result = await deletePipelineRun(await buildPipelineOrchestratorDeps(), input);
|
|
3065
3810
|
return {
|
|
3066
3811
|
content: [
|
|
3067
3812
|
{ type: "text", text: JSON.stringify(result, null, 2) },
|
|
@@ -3081,6 +3826,12 @@ registerTool("delete_pipeline_run", {
|
|
|
3081
3826
|
// TOOL_HANDLERS and the pipeline tools so the orchestrator can dispatch the
|
|
3082
3827
|
// existing child pipelines in-process.
|
|
3083
3828
|
registerTool("run_full_automation", {
|
|
3829
|
+
annotations: {
|
|
3830
|
+
readOnlyHint: false,
|
|
3831
|
+
destructiveHint: false,
|
|
3832
|
+
idempotentHint: false,
|
|
3833
|
+
openWorldHint: true,
|
|
3834
|
+
},
|
|
3084
3835
|
description: "Run the full-automation chain for an idea: create ticket(s) " +
|
|
3085
3836
|
"(idea-to-ticket), review each created ticket (review-ticket fan-out), " +
|
|
3086
3837
|
"then emit the exact `/start-tickets ...` command for you to invoke in " +
|
|
@@ -3120,7 +3871,7 @@ registerTool("run_full_automation", {
|
|
|
3120
3871
|
if (!resolved.ok) {
|
|
3121
3872
|
return resolved.errorResponse;
|
|
3122
3873
|
}
|
|
3123
|
-
const result = await runFullAutomation(buildChainOrchestratorDeps(), {
|
|
3874
|
+
const result = await runFullAutomation(await buildChainOrchestratorDeps(), {
|
|
3124
3875
|
idea: resolved.text,
|
|
3125
3876
|
...rest,
|
|
3126
3877
|
});
|
|
@@ -3131,6 +3882,12 @@ registerTool("run_full_automation", {
|
|
|
3131
3882
|
};
|
|
3132
3883
|
});
|
|
3133
3884
|
registerTool("resume_full_automation", {
|
|
3885
|
+
annotations: {
|
|
3886
|
+
readOnlyHint: false,
|
|
3887
|
+
destructiveHint: false,
|
|
3888
|
+
idempotentHint: false,
|
|
3889
|
+
openWorldHint: true,
|
|
3890
|
+
},
|
|
3134
3891
|
description: "Resume a paused full-automation chain run. Provide the `chain_run_id` " +
|
|
3135
3892
|
"returned by the prior needs_agent_task envelope and the string the " +
|
|
3136
3893
|
"instruction asked you to produce as `agent_result`. Returns the same " +
|
|
@@ -3140,7 +3897,7 @@ registerTool("resume_full_automation", {
|
|
|
3140
3897
|
agent_result: z.string(),
|
|
3141
3898
|
},
|
|
3142
3899
|
}, async (input) => {
|
|
3143
|
-
const result = await resumeFullAutomation(buildChainOrchestratorDeps(), input);
|
|
3900
|
+
const result = await resumeFullAutomation(await buildChainOrchestratorDeps(), input);
|
|
3144
3901
|
return {
|
|
3145
3902
|
content: [
|
|
3146
3903
|
{ type: "text", text: JSON.stringify(result, null, 2) },
|
|
@@ -3150,12 +3907,125 @@ registerTool("resume_full_automation", {
|
|
|
3150
3907
|
// ---------------------------------------------------------------------------
|
|
3151
3908
|
// generate_decision_page
|
|
3152
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
|
+
}
|
|
3153
4015
|
registerTool("generate_decision_page", {
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
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.",
|
|
3159
4029
|
inputSchema: DecisionPageInputShape,
|
|
3160
4030
|
}, async (input) => {
|
|
3161
4031
|
// Returned messages travel through JSON.stringify in the MCP envelope below,
|
|
@@ -3205,9 +4075,19 @@ registerTool("generate_decision_page", {
|
|
|
3205
4075
|
}
|
|
3206
4076
|
seenCiIds.add(ci.id);
|
|
3207
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
|
+
}
|
|
3208
4087
|
// Read design assets and base64-encode for embedding
|
|
3209
|
-
const
|
|
3210
|
-
const
|
|
4088
|
+
const projectRootForAssets = await getProjectRoot();
|
|
4089
|
+
const assetsDir = path.join(projectRootForAssets, "design-assets");
|
|
4090
|
+
const fontsDir = path.join(projectRootForAssets, "public", "fonts");
|
|
3211
4091
|
let faviconBase64 = "";
|
|
3212
4092
|
let logoBase64 = "";
|
|
3213
4093
|
try {
|
|
@@ -3220,15 +4100,16 @@ registerTool("generate_decision_page", {
|
|
|
3220
4100
|
logoBase64 = logoBuf.toString("base64");
|
|
3221
4101
|
}
|
|
3222
4102
|
catch { /* logo optional */ }
|
|
3223
|
-
//
|
|
3224
|
-
|
|
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;
|
|
3225
4107
|
const fontsRelPath = path.relative(docsPath, fontsDir);
|
|
3226
4108
|
const html = generateDecisionPageHtml(input, {
|
|
3227
4109
|
faviconBase64,
|
|
3228
4110
|
logoBase64,
|
|
3229
4111
|
fontsRelPath,
|
|
3230
4112
|
});
|
|
3231
|
-
const filePath = path.join(docsPath, `${input.ticket_key}-decisions.html`);
|
|
3232
4113
|
await mkdir(docsPath, { recursive: true });
|
|
3233
4114
|
await writeFile(filePath, html, "utf-8");
|
|
3234
4115
|
return {
|
|
@@ -3244,10 +4125,79 @@ registerTool("generate_decision_page", {
|
|
|
3244
4125
|
};
|
|
3245
4126
|
});
|
|
3246
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
|
+
// ---------------------------------------------------------------------------
|
|
3247
4194
|
// Entry point
|
|
3248
4195
|
// ---------------------------------------------------------------------------
|
|
3249
4196
|
const transport = new StdioServerTransport();
|
|
3250
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;
|
|
3251
4201
|
console.error("Bridge API MCP server running on stdio");
|
|
3252
4202
|
// Fire-and-forget update check — delay to let MCP client attach listeners
|
|
3253
4203
|
(async () => {
|