@bridge_gpt/mcp-server 0.1.17 → 0.2.1
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 +334 -196
- 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 +25 -17
- package/build/agent-launchers/cursor.js +65 -0
- package/build/agent-launchers/index.js +23 -8
- package/build/agent-registry.js +68 -0
- package/build/agents.generated.js +1 -1
- package/build/brainstorm-files.js +89 -0
- package/build/bridge-config.js +404 -0
- package/build/chain-orchestrator.js +247 -33
- package/build/command-catalog.js +376 -0
- package/build/commands.generated.js +10 -7
- 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 +1707 -557
- package/build/mcp-invoke.js +417 -0
- package/build/mcp-provisioning.js +342 -0
- package/build/mcp-registration-doctor.js +96 -0
- package/build/pipeline-orchestrator.js +9 -1
- package/build/pipelines.generated.js +5 -3
- package/build/schedule-run.js +440 -92
- package/build/schedule-store.js +41 -1
- package/build/scheduled-prompt.js +109 -0
- package/build/scheduler-backends/at-fallback.js +5 -10
- package/build/scheduler-backends/escaping.js +40 -10
- package/build/scheduler-backends/launchd.js +23 -14
- package/build/scheduler-backends/systemd-user.js +32 -19
- package/build/scheduler-backends/task-scheduler.js +8 -13
- package/build/start-tickets-prereqs.js +90 -1
- package/build/start-tickets.js +563 -42
- package/build/third-party-mcp-targets.js +75 -0
- package/build/version.generated.js +1 -1
- package/package.json +4 -3
- package/pipelines/full-automation.json +3 -1
- package/smoke-test/SMOKE-TEST.md +62 -17
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,137 @@ 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 {
|
|
91
|
+
"X-API-Key": await getResolvedApiKey(),
|
|
92
|
+
"X-Bridge-MCP-Version": VERSION,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** POST auth headers (API key + JSON content type). */
|
|
96
|
+
async function getPostHeaders() {
|
|
97
|
+
return {
|
|
98
|
+
"X-API-Key": await getResolvedApiKey(),
|
|
99
|
+
"Content-Type": "application/json",
|
|
100
|
+
"X-Bridge-MCP-Version": VERSION,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/** Set true immediately after the server connection (transport) completes. */
|
|
104
|
+
let serverConnected = false;
|
|
105
|
+
/**
|
|
106
|
+
* Query MCP `roots/list` through the connected server and return the first
|
|
107
|
+
* usable `file://` root path, or null. Only attempted after the server has
|
|
108
|
+
* connected; all failures are caught and yield null (the caller falls through).
|
|
109
|
+
*/
|
|
110
|
+
async function resolveProjectRootFromRootsList() {
|
|
111
|
+
if (!serverConnected)
|
|
112
|
+
return null;
|
|
113
|
+
try {
|
|
114
|
+
const result = await server.server.listRoots();
|
|
115
|
+
const roots = Array.isArray(result?.roots) ? result.roots : [];
|
|
116
|
+
for (const root of roots) {
|
|
117
|
+
const uri = root?.uri;
|
|
118
|
+
if (typeof uri === "string" && uri.startsWith("file://")) {
|
|
119
|
+
try {
|
|
120
|
+
return fileURLToPath(uri);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
/* not a usable file URI — keep scanning */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
let projectRootPromise;
|
|
134
|
+
/**
|
|
135
|
+
* Resolve the project root once, in the exact order:
|
|
136
|
+
* 1. `BAPI_PROJECT_ROOT` env (always wins),
|
|
137
|
+
* 2. connected MCP `roots/list`,
|
|
138
|
+
* 3. `CLAUDE_PROJECT_DIR` env,
|
|
139
|
+
* 4. `process.cwd()`.
|
|
140
|
+
*
|
|
141
|
+
* INVARIANT: the FIRST call must happen inside a tool handler (i.e. AFTER the
|
|
142
|
+
* server connects), so the `roots/list` branch is reachable. This holds today —
|
|
143
|
+
* custom-pipeline loading is deferred and `checkCiConfigAndDisablePoll()` only
|
|
144
|
+
* resolves headers, not paths. If any future module-load code path calls this
|
|
145
|
+
* (or `getDocsDir()` / `getPipelinesDir()`) before connect, it would permanently
|
|
146
|
+
* cache a `cwd` / `CLAUDE_PROJECT_DIR` fallback even when a `file://` root exists.
|
|
147
|
+
*/
|
|
148
|
+
async function getProjectRoot() {
|
|
149
|
+
if (!projectRootPromise) {
|
|
150
|
+
projectRootPromise = (async () => {
|
|
151
|
+
const explicit = (process.env.BAPI_PROJECT_ROOT ?? "").trim();
|
|
152
|
+
if (explicit.length > 0)
|
|
153
|
+
return explicit;
|
|
154
|
+
const fromRoots = await resolveProjectRootFromRootsList();
|
|
155
|
+
if (fromRoots && fromRoots.length > 0)
|
|
156
|
+
return fromRoots;
|
|
157
|
+
const claudeDir = (process.env.CLAUDE_PROJECT_DIR ?? "").trim();
|
|
158
|
+
if (claudeDir.length > 0)
|
|
159
|
+
return claudeDir;
|
|
160
|
+
return process.cwd();
|
|
161
|
+
})();
|
|
162
|
+
}
|
|
163
|
+
return projectRootPromise;
|
|
164
|
+
}
|
|
165
|
+
let docsDirPromise;
|
|
166
|
+
/** Resolve `BAPI_DOCS_DIR` (default `docs/tmp`) against the project root, once. */
|
|
167
|
+
async function getDocsDir() {
|
|
168
|
+
if (!docsDirPromise) {
|
|
169
|
+
docsDirPromise = (async () => path.resolve(await getProjectRoot(), process.env.BAPI_DOCS_DIR ?? "docs/tmp"))();
|
|
170
|
+
}
|
|
171
|
+
return docsDirPromise;
|
|
172
|
+
}
|
|
173
|
+
let pipelinesDirPromise;
|
|
174
|
+
/** Resolve `BAPI_PIPELINES_DIR` (default `.bridge/pipelines`) against the root, once. */
|
|
175
|
+
async function getPipelinesDir() {
|
|
176
|
+
if (!pipelinesDirPromise) {
|
|
177
|
+
pipelinesDirPromise = (async () => path.resolve(await getProjectRoot(), process.env.BAPI_PIPELINES_DIR ?? ".bridge/pipelines"))();
|
|
178
|
+
}
|
|
179
|
+
return pipelinesDirPromise;
|
|
180
|
+
}
|
|
51
181
|
// ---------------------------------------------------------------------------
|
|
52
182
|
// Helpers
|
|
53
183
|
// ---------------------------------------------------------------------------
|
|
@@ -64,18 +194,32 @@ function buildGetUrl(path, params) {
|
|
|
64
194
|
}
|
|
65
195
|
return url.toString();
|
|
66
196
|
}
|
|
67
|
-
function getDocsPath(subdir) {
|
|
68
|
-
return path.join(
|
|
197
|
+
async function getDocsPath(subdir) {
|
|
198
|
+
return path.join(await getDocsDir(), subdir);
|
|
69
199
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Merge custom user pipelines (from the resolved pipelines dir) into PIPELINES /
|
|
202
|
+
* INSTRUCTIONS / userPipelineKeys exactly once. Deferred (not run at module load)
|
|
203
|
+
* so it can await the project-root resolution that depends on `roots/list`.
|
|
204
|
+
*/
|
|
205
|
+
let customPipelinesPromise;
|
|
206
|
+
async function ensureCustomPipelinesLoaded() {
|
|
207
|
+
if (!customPipelinesPromise) {
|
|
208
|
+
customPipelinesPromise = (async () => {
|
|
209
|
+
const pipelinesDir = await getPipelinesDir();
|
|
210
|
+
const instructionsDir = path.join(path.dirname(pipelinesDir), "instructions");
|
|
211
|
+
const customResult = await loadCustomPipelines(pipelinesDir, instructionsDir, BUNDLED_INSTRUCTIONS);
|
|
212
|
+
for (const [key, pipeline] of Object.entries(customResult.pipelines)) {
|
|
213
|
+
if (key in BUNDLED_PIPELINES) {
|
|
214
|
+
console.error(`Warning: user pipeline "${key}" overrides bundled pipeline.`);
|
|
215
|
+
}
|
|
216
|
+
PIPELINES[key] = pipeline;
|
|
217
|
+
}
|
|
218
|
+
Object.assign(INSTRUCTIONS, customResult.instructions);
|
|
219
|
+
userPipelineKeys = customResult.userPipelineKeys;
|
|
220
|
+
})();
|
|
221
|
+
}
|
|
222
|
+
return customPipelinesPromise;
|
|
79
223
|
}
|
|
80
224
|
const ERROR_CODES = {
|
|
81
225
|
400: "BAD_REQUEST",
|
|
@@ -104,6 +248,29 @@ async function handleResponse(resp) {
|
|
|
104
248
|
let message = rawText;
|
|
105
249
|
try {
|
|
106
250
|
const parsed = JSON.parse(rawText);
|
|
251
|
+
if (parsed.detail !== null &&
|
|
252
|
+
typeof parsed.detail === "object" &&
|
|
253
|
+
!Array.isArray(parsed.detail)) {
|
|
254
|
+
// Structured FastAPI HTTPException(detail={...}): preserve fields like
|
|
255
|
+
// error_kind and remediation at the top level so agents can read them
|
|
256
|
+
// natively instead of an opaque stringified blob.
|
|
257
|
+
const detail = parsed.detail;
|
|
258
|
+
if (typeof detail.message === "string") {
|
|
259
|
+
message = detail.message;
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
message = JSON.stringify(detail);
|
|
263
|
+
}
|
|
264
|
+
// Spread detail FIRST so the relay's own error code / HTTP status /
|
|
265
|
+
// message always win — a future detail object that happens to carry an
|
|
266
|
+
// `error`/`status`/`message` key cannot clobber the relay envelope.
|
|
267
|
+
return JSON.stringify({
|
|
268
|
+
...detail,
|
|
269
|
+
error: errorCode,
|
|
270
|
+
status: resp.status,
|
|
271
|
+
message,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
107
274
|
if (parsed.detail) {
|
|
108
275
|
message = typeof parsed.detail === "string" ? parsed.detail : JSON.stringify(parsed.detail);
|
|
109
276
|
}
|
|
@@ -133,7 +300,7 @@ async function createTicketRequest(params) {
|
|
|
133
300
|
payload.parent_key = params.parent_key;
|
|
134
301
|
const resp = await fetch(buildUrl("/create-ticket"), {
|
|
135
302
|
method: "POST",
|
|
136
|
-
headers:
|
|
303
|
+
headers: await getPostHeaders(),
|
|
137
304
|
body: JSON.stringify(payload),
|
|
138
305
|
});
|
|
139
306
|
return handleResponse(resp);
|
|
@@ -149,6 +316,100 @@ async function saveLocally(dir, filename, content) {
|
|
|
149
316
|
return `\n\n---\nNote: Failed to save file to ${filePath}: ${writeErr}`;
|
|
150
317
|
}
|
|
151
318
|
}
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// Heavy-read truncate-and-save helpers (BAPI-342)
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
//
|
|
323
|
+
// Five heavy read tools (get_project_standards, get_tickets, get_ticket,
|
|
324
|
+
// get_comments, list_attachments) can return very large payloads. To avoid flooding the
|
|
325
|
+
// agent's context, when a successful payload exceeds MAX_INLINE_TEXT_LENGTH the
|
|
326
|
+
// FULL payload is saved to disk first and only then is a truncated,
|
|
327
|
+
// markdown-fenced inline preview returned. Nothing is lost: if the save fails
|
|
328
|
+
// (or the target escapes the docs directory) we fall back to returning the
|
|
329
|
+
// complete untruncated payload with a warning rather than dropping data.
|
|
330
|
+
// Shared with download_attachment, which uses MAX_INLINE_TEXT_LENGTH directly.
|
|
331
|
+
const MAX_INLINE_TEXT_LENGTH = 50_000;
|
|
332
|
+
// Filesystem-safe ISO timestamp (no ":" or "." which are awkward in filenames).
|
|
333
|
+
function safeTimestampForFilename() {
|
|
334
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
335
|
+
}
|
|
336
|
+
// Sanitize an untrusted ticket number into a single safe filename segment.
|
|
337
|
+
// Preserves readable keys like "BAPI-123" while stripping path separators and
|
|
338
|
+
// other unsafe characters; falls back to "ticket" when nothing usable remains.
|
|
339
|
+
function safeTicketFileSegment(ticketNumber) {
|
|
340
|
+
const base = path.basename(ticketNumber.trim());
|
|
341
|
+
const cleaned = base
|
|
342
|
+
.replace(/[^A-Za-z0-9_-]/g, "-")
|
|
343
|
+
.replace(/-+/g, "-")
|
|
344
|
+
.replace(/^-+|-+$/g, "");
|
|
345
|
+
return cleaned || "ticket";
|
|
346
|
+
}
|
|
347
|
+
// Mirror the download_attachment containment check: a resolved save target must
|
|
348
|
+
// live strictly inside the resolved directory. Prevents traversal escapes from
|
|
349
|
+
// a crafted filename even though saveLocally does a bare path.join.
|
|
350
|
+
function isContainedSaveTarget(dir, filename) {
|
|
351
|
+
const resolvedDir = path.resolve(dir);
|
|
352
|
+
const target = path.resolve(resolvedDir, filename);
|
|
353
|
+
return target.startsWith(resolvedDir + path.sep);
|
|
354
|
+
}
|
|
355
|
+
// saveLocally returns either a "Saved to <path>" note or a "Failed to save"
|
|
356
|
+
// note (it never throws). A successful save is identified by the "Saved to "
|
|
357
|
+
// marker.
|
|
358
|
+
function saveLocallySucceeded(note) {
|
|
359
|
+
return note.includes("Saved to ");
|
|
360
|
+
}
|
|
361
|
+
// Save-before-truncate for heavy read payloads. Returns `text` unchanged when
|
|
362
|
+
// it is within the inline limit. Otherwise it saves the full payload first and
|
|
363
|
+
// only truncates after a confirmed, contained save; on any containment or save
|
|
364
|
+
// failure it returns the FULL untruncated text plus a warning so nothing is
|
|
365
|
+
// lost. Returns JSON inside a markdown code fence so a truncated mid-string
|
|
366
|
+
// slice is never mistaken for parseable JSON.
|
|
367
|
+
async function truncateAndSaveIfNeeded(text, dir, filename) {
|
|
368
|
+
if (text.length <= MAX_INLINE_TEXT_LENGTH) {
|
|
369
|
+
return text;
|
|
370
|
+
}
|
|
371
|
+
if (!isContainedSaveTarget(dir, filename)) {
|
|
372
|
+
return (text +
|
|
373
|
+
"\n\n---\nWarning: response was NOT truncated because the save target " +
|
|
374
|
+
`was rejected as outside the docs directory (dir=${dir}, filename=${filename}).`);
|
|
375
|
+
}
|
|
376
|
+
const note = await saveLocally(dir, filename, text);
|
|
377
|
+
if (!saveLocallySucceeded(note)) {
|
|
378
|
+
return (text +
|
|
379
|
+
note +
|
|
380
|
+
"\n\nWarning: response was NOT truncated because the full payload could not be saved.");
|
|
381
|
+
}
|
|
382
|
+
const truncated = text.slice(0, MAX_INLINE_TEXT_LENGTH);
|
|
383
|
+
return ("[Response truncated. Full response saved locally.]\n\n" +
|
|
384
|
+
"```json\n" +
|
|
385
|
+
truncated +
|
|
386
|
+
"\n```" +
|
|
387
|
+
note);
|
|
388
|
+
}
|
|
389
|
+
// Build a stable, readable, collision-resistant filename for an oversized
|
|
390
|
+
// get_tickets search payload. Includes the active filters and pagination so
|
|
391
|
+
// distinct search pages do not collide, plus a timestamp; falls back to "all"
|
|
392
|
+
// when no filter produces a slug.
|
|
393
|
+
function buildTicketsSearchFilename(params) {
|
|
394
|
+
const parts = [];
|
|
395
|
+
if (params.query)
|
|
396
|
+
parts.push(params.query);
|
|
397
|
+
if (params.status)
|
|
398
|
+
parts.push(params.status);
|
|
399
|
+
const labels = Array.isArray(params.labels)
|
|
400
|
+
? params.labels.join("-")
|
|
401
|
+
: params.labels;
|
|
402
|
+
if (labels)
|
|
403
|
+
parts.push(labels);
|
|
404
|
+
if (params.updated_since)
|
|
405
|
+
parts.push(`since-${params.updated_since}`);
|
|
406
|
+
if (params.limit !== undefined)
|
|
407
|
+
parts.push(`limit-${params.limit}`);
|
|
408
|
+
if (params.offset !== undefined)
|
|
409
|
+
parts.push(`offset-${params.offset}`);
|
|
410
|
+
const slug = slugify(parts.join("-")) || "all";
|
|
411
|
+
return `search-${slug}-${safeTimestampForFilename()}.json`;
|
|
412
|
+
}
|
|
152
413
|
async function resolveTextOrFile(textValue, filePath, textLabel) {
|
|
153
414
|
if (!filePath && !textValue) {
|
|
154
415
|
return {
|
|
@@ -209,7 +470,7 @@ async function pollForResult(getUrl, timeoutMs, label) {
|
|
|
209
470
|
};
|
|
210
471
|
}
|
|
211
472
|
console.error(`${label} in progress... (elapsed: ${elapsed}s)`);
|
|
212
|
-
const resp = await fetch(getUrl, { headers:
|
|
473
|
+
const resp = await fetch(getUrl, { headers: await getGetHeaders() });
|
|
213
474
|
if (resp.status === 404 || resp.status === 202) {
|
|
214
475
|
await resp.text();
|
|
215
476
|
}
|
|
@@ -223,36 +484,333 @@ async function pollForResult(getUrl, timeoutMs, label) {
|
|
|
223
484
|
}
|
|
224
485
|
}
|
|
225
486
|
}
|
|
487
|
+
const TICKET_ARTIFACTS = {
|
|
488
|
+
plan: {
|
|
489
|
+
kind: "single",
|
|
490
|
+
generateEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/generate-plan`,
|
|
491
|
+
getEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/plan`,
|
|
492
|
+
saveSubdir: "plans",
|
|
493
|
+
filename: (n) => `${n}-plan.md`,
|
|
494
|
+
requestErrorPrefix: "Failed to request plan generation: ",
|
|
495
|
+
confirmationText: (n) => `Plan generation requested for ${n}. ` +
|
|
496
|
+
`Processing typically takes 1-5 minutes. ` +
|
|
497
|
+
`Use get_plan with ticket_number "${n}" to retrieve the plan once processing completes.`,
|
|
498
|
+
pollLabel: (n) => `Plan generation for ${n}`,
|
|
499
|
+
},
|
|
500
|
+
architecture: {
|
|
501
|
+
kind: "single",
|
|
502
|
+
generateEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/generate-architecture`,
|
|
503
|
+
getEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/architecture-plan`,
|
|
504
|
+
saveSubdir: "architecture",
|
|
505
|
+
filename: (n) => `${n}-architecture-plan.md`,
|
|
506
|
+
requestErrorPrefix: "Failed to request architecture generation: ",
|
|
507
|
+
confirmationText: (n) => `Architecture generation requested for ${n}. ` +
|
|
508
|
+
`Processing typically takes 2-4 minutes. ` +
|
|
509
|
+
`Use get_architecture with ticket_number "${n}" to retrieve the architecture plan once processing completes.`,
|
|
510
|
+
pollLabel: (n) => `Architecture generation for ${n}`,
|
|
511
|
+
},
|
|
512
|
+
fsd: {
|
|
513
|
+
kind: "single",
|
|
514
|
+
generateEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/generate-fsd`,
|
|
515
|
+
getEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/fsd`,
|
|
516
|
+
saveSubdir: "fsd",
|
|
517
|
+
filename: (n) => `${n}-fsd-plan.md`,
|
|
518
|
+
requestErrorPrefix: "Failed to request FSD generation: ",
|
|
519
|
+
confirmationText: (n) => `FSD generation requested for ${n}. ` +
|
|
520
|
+
`Processing typically takes 2-4 minutes. ` +
|
|
521
|
+
`Use get_design_doc with ticket_number "${n}" and doc_type "fsd" to retrieve the functional specification document once processing completes.`,
|
|
522
|
+
pollLabel: (n) => `FSD generation for ${n}`,
|
|
523
|
+
},
|
|
524
|
+
clarifying_questions: {
|
|
525
|
+
kind: "single",
|
|
526
|
+
generateEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/generate-clarifying-questions`,
|
|
527
|
+
getEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/clarifying-questions`,
|
|
528
|
+
saveSubdir: "clarifying-questions",
|
|
529
|
+
filename: (n) => `${n}-clarifying-questions.md`,
|
|
530
|
+
requestErrorPrefix: "Failed to request clarifying questions: ",
|
|
531
|
+
confirmationText: (n) => `Clarifying questions requested for ${n}. ` +
|
|
532
|
+
`Processing typically takes 1-5 minutes. ` +
|
|
533
|
+
`Use get_clarifying_questions with ticket_number "${n}" to retrieve the results once processing completes.`,
|
|
534
|
+
pollLabel: (n) => `Clarifying questions for ${n}`,
|
|
535
|
+
},
|
|
536
|
+
ticket_critique: {
|
|
537
|
+
kind: "single",
|
|
538
|
+
generateEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/generate-ticket-critique`,
|
|
539
|
+
getEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/ticket-critique`,
|
|
540
|
+
saveSubdir: "ticket-critiques",
|
|
541
|
+
filename: (n) => `${n}-ticket-quality-critique.md`,
|
|
542
|
+
requestErrorPrefix: "Failed to request ticket critique: ",
|
|
543
|
+
confirmationText: (n) => `Ticket critique requested for ${n}. ` +
|
|
544
|
+
`Processing typically takes 1-5 minutes. ` +
|
|
545
|
+
`Use get_ticket_critique with ticket_number "${n}" to retrieve the results once processing completes.`,
|
|
546
|
+
pollLabel: (n) => `Ticket critique for ${n}`,
|
|
547
|
+
},
|
|
548
|
+
reimplement_context: {
|
|
549
|
+
kind: "single",
|
|
550
|
+
generateEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/request-reimplement-context`,
|
|
551
|
+
getEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/reimplement-context`,
|
|
552
|
+
saveSubdir: "reimplementations",
|
|
553
|
+
filename: (n) => `${n}-context.md`,
|
|
554
|
+
requestErrorPrefix: "Failed to request reimplement context: ",
|
|
555
|
+
confirmationText: (n) => `Reimplement context processing requested for ${n}. ` +
|
|
556
|
+
`Processing typically takes 1-2 minutes. ` +
|
|
557
|
+
`Use get_reimplement_context with ticket_number "${n}" to retrieve the results once processing completes.`,
|
|
558
|
+
pollLabel: (n) => `Reimplement context for ${n}`,
|
|
559
|
+
},
|
|
560
|
+
ticket_review: {
|
|
561
|
+
kind: "review",
|
|
562
|
+
generateEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/generate-ticket-review`,
|
|
563
|
+
getEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/ticket-review`,
|
|
564
|
+
requestErrorPrefix: "Failed to request ticket review: ",
|
|
565
|
+
confirmationText: (n) => `Combined ticket review requested for ${n}. ` +
|
|
566
|
+
`Processing typically takes 2-6 minutes. ` +
|
|
567
|
+
`Use get_clarifying_questions and get_ticket_critique with ticket_number "${n}" to retrieve the two documents once processing completes.`,
|
|
568
|
+
pollLabel: (n) => `Ticket review for ${n}`,
|
|
569
|
+
clarifySaveSubdir: "clarifying-questions",
|
|
570
|
+
critiqueSaveSubdir: "ticket-critiques",
|
|
571
|
+
},
|
|
572
|
+
};
|
|
573
|
+
// Build the shared POST body for an artifact generate request. Preserves the
|
|
574
|
+
// per-tool provider routing exactly: a trimmed `second_opinion` becomes
|
|
575
|
+
// `provider_override` and takes precedence; otherwise a trimmed `provider`
|
|
576
|
+
// becomes `provider`. Blank values are omitted after trimming.
|
|
577
|
+
function buildTicketArtifactRequestBody(args) {
|
|
578
|
+
const body = { repo_name: REPO_NAME };
|
|
579
|
+
const trimmedSecondOpinion = args.second_opinion?.trim();
|
|
580
|
+
if (trimmedSecondOpinion) {
|
|
581
|
+
body.provider_override = trimmedSecondOpinion;
|
|
582
|
+
}
|
|
583
|
+
const trimmedProvider = args.provider?.trim();
|
|
584
|
+
if (trimmedProvider && !trimmedSecondOpinion) {
|
|
585
|
+
body.provider = trimmedProvider;
|
|
586
|
+
}
|
|
587
|
+
return body;
|
|
588
|
+
}
|
|
589
|
+
// Resolve the docs save directory for an artifact subdir. Implemented as an
|
|
590
|
+
// explicit switch with literal getDocsPath(...) calls so the existing static
|
|
591
|
+
// source-text invariants (literal getDocsPath subdir strings) still hold.
|
|
592
|
+
async function getTicketArtifactDocsPath(subdir) {
|
|
593
|
+
switch (subdir) {
|
|
594
|
+
case "plans":
|
|
595
|
+
return getDocsPath("plans");
|
|
596
|
+
case "architecture":
|
|
597
|
+
return getDocsPath("architecture");
|
|
598
|
+
case "fsd":
|
|
599
|
+
return getDocsPath("fsd");
|
|
600
|
+
case "clarifying-questions":
|
|
601
|
+
return getDocsPath("clarifying-questions");
|
|
602
|
+
case "ticket-critiques":
|
|
603
|
+
return getDocsPath("ticket-critiques");
|
|
604
|
+
case "reimplementations":
|
|
605
|
+
return getDocsPath("reimplementations");
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// Map the public unified design-doc `doc_type` to the internal artifact key.
|
|
609
|
+
// `tdd` reuses the existing "architecture" artifact (the TDD/architecture
|
|
610
|
+
// document) WITHOUT renaming it, preserving the back-compatible
|
|
611
|
+
// request_architecture / get_architecture tools. `fsd` routes to the new "fsd"
|
|
612
|
+
// artifact.
|
|
613
|
+
function resolveDesignDocArtifactType(docType) {
|
|
614
|
+
return docType === "fsd" ? "fsd" : "architecture";
|
|
615
|
+
}
|
|
616
|
+
// Shared request flow for the five single-artifact request_* tools: POST the
|
|
617
|
+
// generate endpoint, return the per-tool error prefix on a non-OK POST, and on
|
|
618
|
+
// wait_for_result poll the get endpoint (900_000 ms) and optionally save.
|
|
619
|
+
async function requestTicketArtifact(type, args) {
|
|
620
|
+
const config = TICKET_ARTIFACTS[type];
|
|
621
|
+
const resp = await fetch(buildUrl(config.generateEndpoint(args.ticket_number)), {
|
|
622
|
+
method: "POST",
|
|
623
|
+
headers: await getPostHeaders(),
|
|
624
|
+
body: JSON.stringify(buildTicketArtifactRequestBody(args)),
|
|
625
|
+
});
|
|
626
|
+
if (!resp.ok) {
|
|
627
|
+
const errorText = await handleResponse(resp);
|
|
628
|
+
return {
|
|
629
|
+
content: [{ type: "text", text: `${config.requestErrorPrefix}${errorText}` }],
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
if (args.wait_for_result) {
|
|
633
|
+
const getUrl = buildGetUrl(config.getEndpoint(args.ticket_number), { repo_name: REPO_NAME });
|
|
634
|
+
const result = await pollForResult(getUrl, 900_000, config.pollLabel(args.ticket_number));
|
|
635
|
+
if (!result.ok) {
|
|
636
|
+
return { content: [{ type: "text", text: result.text }] };
|
|
637
|
+
}
|
|
638
|
+
let text = result.text;
|
|
639
|
+
if (args.save_locally) {
|
|
640
|
+
const note = await saveLocally(await getTicketArtifactDocsPath(config.saveSubdir), config.filename(args.ticket_number), text);
|
|
641
|
+
text += note;
|
|
642
|
+
}
|
|
643
|
+
return { content: [{ type: "text", text }] };
|
|
644
|
+
}
|
|
645
|
+
return {
|
|
646
|
+
content: [{ type: "text", text: config.confirmationText(args.ticket_number) }],
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
// Shared GET/save flow for the five single-artifact get_* tools. Normal gets
|
|
650
|
+
// save only on `resp.ok && save_locally`. reimplement_context is asymmetric: it
|
|
651
|
+
// short-circuits a 404 with a custom NOT_FOUND envelope (without leaking the
|
|
652
|
+
// backend body) and otherwise saves on ANY non-404 response when save_locally.
|
|
653
|
+
async function getTicketArtifact(type, args) {
|
|
654
|
+
const config = TICKET_ARTIFACTS[type];
|
|
655
|
+
const resp = await fetch(buildGetUrl(config.getEndpoint(args.ticket_number), { repo_name: REPO_NAME }), { headers: await getGetHeaders() });
|
|
656
|
+
if (type === "reimplement_context") {
|
|
657
|
+
if (resp.status === 404) {
|
|
658
|
+
return {
|
|
659
|
+
content: [{
|
|
660
|
+
type: "text",
|
|
661
|
+
text: JSON.stringify({
|
|
662
|
+
error: "NOT_FOUND",
|
|
663
|
+
message: `Reimplement context for ${args.ticket_number} is not yet available. ` +
|
|
664
|
+
`Processing may still be in progress. Try again in a moment, ` +
|
|
665
|
+
`or call request_reimplement_context to trigger processing.`,
|
|
666
|
+
}),
|
|
667
|
+
}],
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
const text = await handleResponse(resp);
|
|
671
|
+
if (args.save_locally) {
|
|
672
|
+
const note = await saveLocally(await getTicketArtifactDocsPath(config.saveSubdir), config.filename(args.ticket_number), text);
|
|
673
|
+
return { content: [{ type: "text", text: text + note }] };
|
|
674
|
+
}
|
|
675
|
+
return { content: [{ type: "text", text }] };
|
|
676
|
+
}
|
|
677
|
+
const ok = resp.ok;
|
|
678
|
+
let text = await handleResponse(resp);
|
|
679
|
+
if (ok && args.save_locally) {
|
|
680
|
+
const note = await saveLocally(await getTicketArtifactDocsPath(config.saveSubdir), config.filename(args.ticket_number), text);
|
|
681
|
+
text += note;
|
|
682
|
+
}
|
|
683
|
+
return { content: [{ type: "text", text }] };
|
|
684
|
+
}
|
|
685
|
+
// Bespoke combined ticket-review flow. The backend GET returns a JSON envelope
|
|
686
|
+
// ({ clarify, critique }) rather than a single artifact, so this is kept
|
|
687
|
+
// separate from requestTicketArtifact: it fans the envelope out into two
|
|
688
|
+
// markdown parts joined by "\n\n---\n\n" and saves each sub-doc (when present
|
|
689
|
+
// with a doc_type) into its own subdir using a doc_type-derived filename.
|
|
690
|
+
async function requestTicketReview(args) {
|
|
691
|
+
const config = TICKET_ARTIFACTS.ticket_review;
|
|
692
|
+
const resp = await fetch(buildUrl(config.generateEndpoint(args.ticket_number)), {
|
|
693
|
+
method: "POST",
|
|
694
|
+
headers: await getPostHeaders(),
|
|
695
|
+
body: JSON.stringify(buildTicketArtifactRequestBody(args)),
|
|
696
|
+
});
|
|
697
|
+
if (!resp.ok) {
|
|
698
|
+
const errorText = await handleResponse(resp);
|
|
699
|
+
return {
|
|
700
|
+
content: [{ type: "text", text: `${config.requestErrorPrefix}${errorText}` }],
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
if (!args.wait_for_result) {
|
|
704
|
+
return {
|
|
705
|
+
content: [{ type: "text", text: config.confirmationText(args.ticket_number) }],
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
const getUrl = buildGetUrl(config.getEndpoint(args.ticket_number), { repo_name: REPO_NAME });
|
|
709
|
+
const result = await pollForResult(getUrl, 900_000, config.pollLabel(args.ticket_number));
|
|
710
|
+
if (!result.ok) {
|
|
711
|
+
return { content: [{ type: "text", text: result.text }] };
|
|
712
|
+
}
|
|
713
|
+
// The combined GET endpoint returns JSON. Parse and fan out into markdown.
|
|
714
|
+
let envelope;
|
|
715
|
+
try {
|
|
716
|
+
envelope = JSON.parse(result.text);
|
|
717
|
+
}
|
|
718
|
+
catch (parseErr) {
|
|
719
|
+
return {
|
|
720
|
+
content: [{
|
|
721
|
+
type: "text",
|
|
722
|
+
text: `Failed to parse ticket review response: ${parseErr}\nRaw body: ${result.text}`,
|
|
723
|
+
}],
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
const clarify = envelope.clarify ?? {};
|
|
727
|
+
const critique = envelope.critique ?? {};
|
|
728
|
+
const parts = [];
|
|
729
|
+
let notes = "";
|
|
730
|
+
if (clarify.status === "success" && typeof clarify.content === "string" && clarify.content) {
|
|
731
|
+
parts.push(clarify.content);
|
|
732
|
+
if (args.save_locally && clarify.doc_type) {
|
|
733
|
+
const filename = `${args.ticket_number}-${clarify.doc_type}`;
|
|
734
|
+
const note = await saveLocally(await getTicketArtifactDocsPath(config.clarifySaveSubdir), filename, clarify.content);
|
|
735
|
+
notes += note;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
parts.push(`> **Note:** Clarifying questions sub-flow ${clarify.status ?? "unavailable"} (no content returned).`);
|
|
740
|
+
}
|
|
741
|
+
if (critique.status === "success" && typeof critique.content === "string" && critique.content) {
|
|
742
|
+
parts.push(critique.content);
|
|
743
|
+
if (args.save_locally && critique.doc_type) {
|
|
744
|
+
const filename = `${args.ticket_number}-${critique.doc_type}`;
|
|
745
|
+
const note = await saveLocally(await getTicketArtifactDocsPath(config.critiqueSaveSubdir), filename, critique.content);
|
|
746
|
+
notes += note;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
else {
|
|
750
|
+
parts.push(`> **Note:** Ticket critique sub-flow ${critique.status ?? "unavailable"} (no content returned).`);
|
|
751
|
+
}
|
|
752
|
+
const text = parts.join("\n\n---\n\n") + notes;
|
|
753
|
+
return { content: [{ type: "text", text }] };
|
|
754
|
+
}
|
|
226
755
|
// ---------------------------------------------------------------------------
|
|
227
756
|
// CLI: --init scaffolds slash commands and MCP configs into the current project
|
|
228
757
|
// ---------------------------------------------------------------------------
|
|
229
758
|
function buildBridgeApiEntry(cwd) {
|
|
759
|
+
// Secret-free by design: BAPI_API_KEY is NEVER scaffolded into generated MCP
|
|
760
|
+
// config. The server self-resolves the key from the environment or the
|
|
761
|
+
// home-dir credential store (~/.config/bridge/credentials.json) at runtime.
|
|
230
762
|
return {
|
|
231
763
|
command: "npx",
|
|
232
764
|
args: ["-y", "@bridge_gpt/mcp-server"],
|
|
233
765
|
env: {
|
|
234
766
|
BAPI_BASE_URL: "https://bridgegpt-api.com",
|
|
235
767
|
BAPI_REPO_NAME: "YOUR_REPO_NAME",
|
|
236
|
-
BAPI_API_KEY: "YOUR_API_KEY",
|
|
237
768
|
BAPI_DOCS_DIR: "docs/tmp",
|
|
238
769
|
BAPI_PROJECT_ROOT: cwd,
|
|
239
770
|
},
|
|
240
771
|
};
|
|
241
772
|
}
|
|
773
|
+
/**
|
|
774
|
+
* Choose the `repo_name` written into a scaffolded `.bridge/config`. Uses the
|
|
775
|
+
* cwd basename when it passes manifest repo-name validation; otherwise falls
|
|
776
|
+
* back to the `YOUR_REPO_NAME` placeholder for the user to edit.
|
|
777
|
+
*/
|
|
778
|
+
export function chooseScaffoldRepoName(cwd) {
|
|
779
|
+
const validated = validateRepoName(path.basename(cwd));
|
|
780
|
+
return validated.ok ? validated.value : "YOUR_REPO_NAME";
|
|
781
|
+
}
|
|
782
|
+
/** Build the secret-free `.bridge/config` TOML scaffolded by `--init`. */
|
|
783
|
+
export function buildBridgeConfigManifest(repoName) {
|
|
784
|
+
return [
|
|
785
|
+
"# Bridge API repository MCP manifest (committed, secret-free, machine-agnostic).",
|
|
786
|
+
"#",
|
|
787
|
+
"# Inherited by every git worktree; start-tickets worktree provisioning reads it",
|
|
788
|
+
"# to decide which Bridge API MCP registrations to write. Do not add credentials,",
|
|
789
|
+
"# connection URLs, or machine-specific paths here — those are resolved at runtime.",
|
|
790
|
+
"",
|
|
791
|
+
`repo_name = "${repoName}"`,
|
|
792
|
+
"",
|
|
793
|
+
"[[mcp]]",
|
|
794
|
+
'target = "bapi"',
|
|
795
|
+
"",
|
|
796
|
+
"# Optional: declare additional (Tier-2) MCP targets here. Each non-bapi target",
|
|
797
|
+
"# names the real command/args to launch and the credential-store bundle that",
|
|
798
|
+
"# supplies its secrets — never the secrets themselves. Uncomment and adapt:",
|
|
799
|
+
"#",
|
|
800
|
+
"# [[mcp]]",
|
|
801
|
+
'# target = "sfcc"',
|
|
802
|
+
'# command = "npx"',
|
|
803
|
+
'# args = ["-y", "@salesforce/b2c-dx-mcp"]',
|
|
804
|
+
'# secret_bundle = "sfcc:YOUR_SANDBOX_ID"',
|
|
805
|
+
"",
|
|
806
|
+
].join("\n");
|
|
807
|
+
}
|
|
242
808
|
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");
|
|
809
|
+
await ensureGitignoredShared(cwd, filePath, {
|
|
810
|
+
readFile: (p) => readFile(p, "utf-8"),
|
|
811
|
+
writeFile: (p, data) => writeFile(p, data, "utf-8"),
|
|
812
|
+
mkdir: (p, options) => mkdir(p, options),
|
|
813
|
+
});
|
|
256
814
|
}
|
|
257
815
|
/**
|
|
258
816
|
* Core initialization logic shared by --init and --upgrade.
|
|
@@ -609,10 +1167,34 @@ body explicitly says to serialize structured output.
|
|
|
609
1167
|
console.log(` ${path.relative(cwd, examplePath)} (written)`);
|
|
610
1168
|
}
|
|
611
1169
|
console.log(` ${path.relative(cwd, instrDir)}/ (ensured)`);
|
|
1170
|
+
// ---- Phase 6b: Scaffold the committed secret-free `.bridge/config` ----
|
|
1171
|
+
// This manifest is committed and inherited by every worktree; it is the
|
|
1172
|
+
// input to start-tickets worktree MCP provisioning. It is intentionally
|
|
1173
|
+
// NOT gitignored (unlike the .mcp.json configs above). Credentials are
|
|
1174
|
+
// resolved at runtime, never written here.
|
|
1175
|
+
const bridgeConfigPath = path.join(cwd, ".bridge", "config");
|
|
1176
|
+
let bridgeConfigExists = false;
|
|
1177
|
+
try {
|
|
1178
|
+
await stat(bridgeConfigPath);
|
|
1179
|
+
bridgeConfigExists = true;
|
|
1180
|
+
}
|
|
1181
|
+
catch { }
|
|
1182
|
+
if (bridgeConfigExists) {
|
|
1183
|
+
console.log(`\n.bridge/config: skipped — already exists`);
|
|
1184
|
+
}
|
|
1185
|
+
else {
|
|
1186
|
+
await mkdir(path.dirname(bridgeConfigPath), { recursive: true });
|
|
1187
|
+
await writeFile(bridgeConfigPath, buildBridgeConfigManifest(chooseScaffoldRepoName(cwd)), "utf-8");
|
|
1188
|
+
console.log(`\n.bridge/config: written`);
|
|
1189
|
+
}
|
|
1190
|
+
console.log(" Credentials are resolved at runtime from BAPI_API_KEY or " +
|
|
1191
|
+
"~/.config/bridge/credentials.json (no secrets are written to .bridge/config).");
|
|
612
1192
|
// ---- Phase 7: Final summary ----
|
|
613
1193
|
if (anyCreatedOrAdded) {
|
|
614
|
-
console.log("\
|
|
615
|
-
"
|
|
1194
|
+
console.log("\nSet BAPI_REPO_NAME in your config files. Do NOT put BAPI_API_KEY in the " +
|
|
1195
|
+
"generated MCP config — supply it via the BAPI_API_KEY environment variable, " +
|
|
1196
|
+
"or store it under \"bapi:<repo_name>\" in ~/.config/bridge/credentials.json. " +
|
|
1197
|
+
"Get your values from the Bridge API setup UI at https://bridgegpt-api.com");
|
|
616
1198
|
}
|
|
617
1199
|
}
|
|
618
1200
|
// ---------------------------------------------------------------------------
|
|
@@ -703,6 +1285,14 @@ async function dispatchCliSubcommand(argv) {
|
|
|
703
1285
|
if (argv[0] === "start-tickets") {
|
|
704
1286
|
return runStartTicketsCli(argv.slice(1));
|
|
705
1287
|
}
|
|
1288
|
+
// The internal `mcp-invoke` worktree shim (BAPI-337) is a positional
|
|
1289
|
+
// subcommand routed before the flag guards and well before MCP server
|
|
1290
|
+
// construction: it resolves identity/credentials from `--project-root` and
|
|
1291
|
+
// then spawns the real server itself, so it must never fall through to the
|
|
1292
|
+
// normal no-subcommand startup path.
|
|
1293
|
+
if (argv[0] === "mcp-invoke") {
|
|
1294
|
+
return runMcpInvokeCli(argv.slice(1));
|
|
1295
|
+
}
|
|
706
1296
|
// The read-only `doctor` subcommand is routed beside start-tickets, before the
|
|
707
1297
|
// flag guards and well before MCP server construction (it never starts the server).
|
|
708
1298
|
if (argv[0] === "doctor") {
|
|
@@ -714,6 +1304,12 @@ async function dispatchCliSubcommand(argv) {
|
|
|
714
1304
|
if (argv[0] === "schedule-run") {
|
|
715
1305
|
return runScheduleRunCli(argv.slice(1));
|
|
716
1306
|
}
|
|
1307
|
+
// The read-only `agent-capabilities` toolkit is likewise a positional subcommand,
|
|
1308
|
+
// routed before the flag guards and never starts the MCP server. It only spawns
|
|
1309
|
+
// disposable agent probes in temp dirs.
|
|
1310
|
+
if (argv[0] === "agent-capabilities") {
|
|
1311
|
+
return runAgentCapabilitiesCli(argv.slice(1));
|
|
1312
|
+
}
|
|
717
1313
|
// --init takes precedence over --upgrade; both are position-independent flags.
|
|
718
1314
|
if (argv.includes("--init")) {
|
|
719
1315
|
return runInitCli(cwd);
|
|
@@ -799,25 +1395,59 @@ const registerTool = ((name, config, handler) => {
|
|
|
799
1395
|
}
|
|
800
1396
|
return toolHandle;
|
|
801
1397
|
});
|
|
802
|
-
// ---------------------------------------------------------------------------
|
|
803
|
-
// Tools
|
|
804
|
-
// ---------------------------------------------------------------------------
|
|
805
1398
|
registerTool("ping", {
|
|
1399
|
+
annotations: {
|
|
1400
|
+
readOnlyHint: true,
|
|
1401
|
+
destructiveHint: false,
|
|
1402
|
+
idempotentHint: true,
|
|
1403
|
+
openWorldHint: true,
|
|
1404
|
+
},
|
|
806
1405
|
description: "Test connectivity to Bridge API. Validates that the API key is accepted and the configured repository is accessible. " +
|
|
807
1406
|
"Returns JSON with {status: 'ok', repo_name: '<configured repo>'}. " +
|
|
1407
|
+
"The response also reports MCP version metadata: mcp_version (your installed MCP server version), " +
|
|
1408
|
+
"latest_mcp_version (latest published), upgrade_available, upgrade_advice (a short, optional note when a newer version is available), " +
|
|
1409
|
+
"and release_state_stale. These are informational — an available upgrade is not an error. " +
|
|
808
1410
|
"Use this as a quick health check before other operations, or to verify your Bridge API configuration is working. " +
|
|
809
1411
|
"A 403 response means the API key is invalid or the repo is not authorized. " +
|
|
810
1412
|
"If the server is unreachable, check that BAPI_BASE_URL points to a running Bridge API instance.",
|
|
811
1413
|
inputSchema: {},
|
|
812
1414
|
}, async () => {
|
|
813
1415
|
const url = buildGetUrl("/ping", { repo_name: REPO_NAME });
|
|
814
|
-
const resp = await fetch(url, { headers:
|
|
815
|
-
|
|
816
|
-
|
|
1416
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
1417
|
+
// Preserve existing behavior for non-OK responses (structured error relay).
|
|
1418
|
+
if (!resp.ok) {
|
|
1419
|
+
const text = await handleResponse(resp);
|
|
1420
|
+
return { content: [{ type: "text", text }] };
|
|
1421
|
+
}
|
|
1422
|
+
// OK: keep the first content item independently JSON.parse()-able, and emit
|
|
1423
|
+
// any upgrade advice as a SEPARATE text item so prose never pollutes the JSON.
|
|
1424
|
+
const raw = await resp.text();
|
|
1425
|
+
try {
|
|
1426
|
+
const body = JSON.parse(raw);
|
|
1427
|
+
const content = [
|
|
1428
|
+
{ type: "text", text: JSON.stringify(body, null, 2) },
|
|
1429
|
+
];
|
|
1430
|
+
if (typeof body.upgrade_advice === "string" && body.upgrade_advice.length > 0) {
|
|
1431
|
+
content.push({ type: "text", text: body.upgrade_advice });
|
|
1432
|
+
}
|
|
1433
|
+
return { content };
|
|
1434
|
+
}
|
|
1435
|
+
catch {
|
|
1436
|
+
// Unexpected non-JSON 200 — fall back to the raw text in one content item.
|
|
1437
|
+
return { content: [{ type: "text", text: raw }] };
|
|
1438
|
+
}
|
|
817
1439
|
});
|
|
818
1440
|
registerTool("second_opinion", {
|
|
819
|
-
|
|
1441
|
+
annotations: {
|
|
1442
|
+
readOnlyHint: true,
|
|
1443
|
+
destructiveHint: false,
|
|
1444
|
+
idempotentHint: true,
|
|
1445
|
+
openWorldHint: true,
|
|
1446
|
+
},
|
|
1447
|
+
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
1448
|
"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. " +
|
|
1449
|
+
"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. " +
|
|
1450
|
+
"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
1451
|
"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
1452
|
"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
1453
|
"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 +1469,7 @@ registerTool("second_opinion", {
|
|
|
839
1469
|
}, async ({ prompt, provider, model }) => {
|
|
840
1470
|
const resp = await fetch(buildApiUrl("/llm/second-opinion"), {
|
|
841
1471
|
method: "POST",
|
|
842
|
-
headers:
|
|
1472
|
+
headers: await getPostHeaders(),
|
|
843
1473
|
body: JSON.stringify({
|
|
844
1474
|
repo_name: REPO_NAME,
|
|
845
1475
|
prompt,
|
|
@@ -850,19 +1480,125 @@ registerTool("second_opinion", {
|
|
|
850
1480
|
const text = await handleResponse(resp);
|
|
851
1481
|
return { content: [{ type: "text", text }] };
|
|
852
1482
|
});
|
|
1483
|
+
registerTool("generate_image", {
|
|
1484
|
+
annotations: {
|
|
1485
|
+
readOnlyHint: false,
|
|
1486
|
+
destructiveHint: false,
|
|
1487
|
+
idempotentHint: false,
|
|
1488
|
+
openWorldHint: true,
|
|
1489
|
+
},
|
|
1490
|
+
description: "Generate an image from a text prompt using a provider image model. " +
|
|
1491
|
+
"This tool spends provider credits on every call — cost scales with quality (low/medium/high). " +
|
|
1492
|
+
"Defaults to low quality to minimize provider spend; increase quality only when fidelity matters. " +
|
|
1493
|
+
"Returns native MCP image content (type: 'image') so the caller receives the image directly. " +
|
|
1494
|
+
"The image is always also saved to the local BAPI_DOCS_DIR/images/ directory. " +
|
|
1495
|
+
"Google Imagen outputs (provider='gemini') include an invisible SynthID watermark applied server-side by Google.",
|
|
1496
|
+
inputSchema: {
|
|
1497
|
+
prompt: z
|
|
1498
|
+
.string()
|
|
1499
|
+
.min(1)
|
|
1500
|
+
.max(8000)
|
|
1501
|
+
.describe("Text prompt sent to the image provider."),
|
|
1502
|
+
provider: z
|
|
1503
|
+
.enum(["openai", "gemini"])
|
|
1504
|
+
.optional()
|
|
1505
|
+
.default("openai")
|
|
1506
|
+
.describe("Image provider. Defaults to 'openai' (gpt-image-2)."),
|
|
1507
|
+
quality: z
|
|
1508
|
+
.enum(["low", "medium", "high"])
|
|
1509
|
+
.optional()
|
|
1510
|
+
.default("low")
|
|
1511
|
+
.describe("Image quality. Defaults to 'low' for cost control."),
|
|
1512
|
+
size: z
|
|
1513
|
+
.enum(["1024x1024", "1024x1536", "1536x1024"])
|
|
1514
|
+
.optional()
|
|
1515
|
+
.default("1024x1024")
|
|
1516
|
+
.describe("Image dimensions. Defaults to '1024x1024'."),
|
|
1517
|
+
},
|
|
1518
|
+
}, async ({ prompt, provider, quality, size }) => {
|
|
1519
|
+
const resp = await fetch(buildApiUrl("/llm/generate-image"), {
|
|
1520
|
+
method: "POST",
|
|
1521
|
+
headers: await getPostHeaders(),
|
|
1522
|
+
body: JSON.stringify({
|
|
1523
|
+
repo_name: REPO_NAME,
|
|
1524
|
+
prompt,
|
|
1525
|
+
provider,
|
|
1526
|
+
quality,
|
|
1527
|
+
size,
|
|
1528
|
+
}),
|
|
1529
|
+
});
|
|
1530
|
+
if (!resp.ok) {
|
|
1531
|
+
const text = await handleResponse(resp);
|
|
1532
|
+
return { content: [{ type: "text", text }] };
|
|
1533
|
+
}
|
|
1534
|
+
const body = (await resp.json());
|
|
1535
|
+
const imageBase64 = body.image_base64;
|
|
1536
|
+
if (typeof imageBase64 !== "string" || imageBase64.length === 0) {
|
|
1537
|
+
return {
|
|
1538
|
+
content: [
|
|
1539
|
+
{
|
|
1540
|
+
type: "text",
|
|
1541
|
+
text: JSON.stringify({ error: "Image generation succeeded but response is missing image_base64", status: 500 }),
|
|
1542
|
+
},
|
|
1543
|
+
],
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
const mimeType = typeof body.mime_type === "string" && body.mime_type.length > 0
|
|
1547
|
+
? body.mime_type
|
|
1548
|
+
: "image/png";
|
|
1549
|
+
const content = [
|
|
1550
|
+
{ type: "image", data: imageBase64, mimeType },
|
|
1551
|
+
];
|
|
1552
|
+
// Always persist to BAPI_DOCS_DIR/images/. A write failure must not discard
|
|
1553
|
+
// the already-paid-for generation, so warn and still return the inline image.
|
|
1554
|
+
try {
|
|
1555
|
+
const imagesDir = await getDocsPath("images");
|
|
1556
|
+
const filename = `generated-image-${safeTimestampForFilename()}.png`;
|
|
1557
|
+
const filePath = `${imagesDir}/${filename}`;
|
|
1558
|
+
await mkdir(imagesDir, { recursive: true });
|
|
1559
|
+
await writeFile(filePath, Buffer.from(imageBase64, "base64"));
|
|
1560
|
+
content.push({ type: "text", text: `Saved to ${filePath}` });
|
|
1561
|
+
}
|
|
1562
|
+
catch (saveErr) {
|
|
1563
|
+
content.push({
|
|
1564
|
+
type: "text",
|
|
1565
|
+
text: `Warning: image generated successfully but local save failed: ${saveErr instanceof Error ? saveErr.message : String(saveErr)}`,
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
return { content };
|
|
1569
|
+
});
|
|
853
1570
|
registerTool("get_project_standards", {
|
|
1571
|
+
annotations: {
|
|
1572
|
+
readOnlyHint: true,
|
|
1573
|
+
destructiveHint: false,
|
|
1574
|
+
idempotentHint: true,
|
|
1575
|
+
openWorldHint: true,
|
|
1576
|
+
},
|
|
854
1577
|
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
1578
|
"Returns structured markdown with sections for project context, architecture instructions, code review correctness standards, testing stack information, and build analysis. " +
|
|
856
1579
|
"Only sections with configured values are included. Returns 404 if no standards are configured. " +
|
|
857
|
-
"Consult these standards before writing or reviewing code to ensure compliance with project conventions."
|
|
1580
|
+
"Consult these standards before writing or reviewing code to ensure compliance with project conventions. " +
|
|
1581
|
+
"Successful oversized output is automatically saved under the local docs directory and returned inline as a truncated preview.",
|
|
858
1582
|
inputSchema: {},
|
|
859
1583
|
}, async () => {
|
|
860
1584
|
const url = buildGetUrl("/project-standards", { repo_name: REPO_NAME });
|
|
861
|
-
const resp = await fetch(url, { headers:
|
|
862
|
-
const
|
|
1585
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
1586
|
+
const ok = resp.ok;
|
|
1587
|
+
let text = await handleResponse(resp);
|
|
1588
|
+
if (ok) {
|
|
1589
|
+
const safeRepo = safeTicketFileSegment(REPO_NAME || "repo");
|
|
1590
|
+
const filename = `${safeRepo}-project-standards.md`;
|
|
1591
|
+
text = await truncateAndSaveIfNeeded(text, await getDocsPath("project-standards"), filename);
|
|
1592
|
+
}
|
|
863
1593
|
return { content: [{ type: "text", text }] };
|
|
864
1594
|
});
|
|
865
1595
|
registerTool("get_tickets", {
|
|
1596
|
+
annotations: {
|
|
1597
|
+
readOnlyHint: true,
|
|
1598
|
+
destructiveHint: false,
|
|
1599
|
+
idempotentHint: true,
|
|
1600
|
+
openWorldHint: true,
|
|
1601
|
+
},
|
|
866
1602
|
description: "Search for and list Jira tickets from the configured project. " +
|
|
867
1603
|
"Filters by query text, status name, label, or date. Returns up to 'limit' tickets ordered by most recently updated. " +
|
|
868
1604
|
"All data is fetched live from Jira. Use get_ticket to retrieve full details for a specific ticket.",
|
|
@@ -913,11 +1649,29 @@ registerTool("get_tickets", {
|
|
|
913
1649
|
if (updated_since)
|
|
914
1650
|
params.updated_since = updated_since;
|
|
915
1651
|
const url = buildGetUrl("/tickets", params);
|
|
916
|
-
const resp = await fetch(url, { headers:
|
|
917
|
-
const
|
|
1652
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
1653
|
+
const ok = resp.ok;
|
|
1654
|
+
let text = await handleResponse(resp);
|
|
1655
|
+
if (ok) {
|
|
1656
|
+
const filename = buildTicketsSearchFilename({
|
|
1657
|
+
query,
|
|
1658
|
+
status,
|
|
1659
|
+
labels,
|
|
1660
|
+
updated_since,
|
|
1661
|
+
limit,
|
|
1662
|
+
offset,
|
|
1663
|
+
});
|
|
1664
|
+
text = await truncateAndSaveIfNeeded(text, await getDocsPath("tickets-search"), filename);
|
|
1665
|
+
}
|
|
918
1666
|
return { content: [{ type: "text", text }] };
|
|
919
1667
|
});
|
|
920
1668
|
registerTool("get_ticket", {
|
|
1669
|
+
annotations: {
|
|
1670
|
+
readOnlyHint: true,
|
|
1671
|
+
destructiveHint: false,
|
|
1672
|
+
idempotentHint: true,
|
|
1673
|
+
openWorldHint: true,
|
|
1674
|
+
},
|
|
921
1675
|
description: "Retrieve full details for a single Jira ticket by its key. " +
|
|
922
1676
|
"Returns summary, status, type, assignee, reporter, description, and timestamps. " +
|
|
923
1677
|
"All data is fetched live from Jira. Use get_tickets to search/list multiple tickets. " +
|
|
@@ -929,11 +1683,46 @@ registerTool("get_ticket", {
|
|
|
929
1683
|
},
|
|
930
1684
|
}, async ({ ticket_number }) => {
|
|
931
1685
|
const url = buildGetUrl(`/tickets/${encodeURIComponent(ticket_number)}`, { repo_name: REPO_NAME });
|
|
932
|
-
const resp = await fetch(url, { headers:
|
|
1686
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
1687
|
+
const ok = resp.ok;
|
|
1688
|
+
let text = await handleResponse(resp);
|
|
1689
|
+
if (ok) {
|
|
1690
|
+
const safeTicket = safeTicketFileSegment(ticket_number);
|
|
1691
|
+
const filename = `${safeTicket}.json`;
|
|
1692
|
+
text = await truncateAndSaveIfNeeded(text, await getDocsPath("tickets"), filename);
|
|
1693
|
+
}
|
|
1694
|
+
return { content: [{ type: "text", text }] };
|
|
1695
|
+
});
|
|
1696
|
+
registerTool("get_ticket_model_tier", {
|
|
1697
|
+
annotations: {
|
|
1698
|
+
readOnlyHint: true,
|
|
1699
|
+
destructiveHint: false,
|
|
1700
|
+
idempotentHint: true,
|
|
1701
|
+
openWorldHint: true,
|
|
1702
|
+
},
|
|
1703
|
+
description: "Resolve the coarse implementation-model TIER for a Jira ticket from its difficulty. " +
|
|
1704
|
+
"Returns { difficulty: int|null, tier: \"cheap\"|\"basic\"|\"premium\"|null, source: \"cached\"|\"computed\"|\"fallback\" }. " +
|
|
1705
|
+
"The backend computes difficulty on demand (and caches it) when missing, and never returns a model id — " +
|
|
1706
|
+
"the tier->model mapping is owned by the start-tickets CLI. A null tier (source=\"fallback\") means the " +
|
|
1707
|
+
"model could not be resolved and callers should use the agent's default model.",
|
|
1708
|
+
inputSchema: {
|
|
1709
|
+
ticket_number: z
|
|
1710
|
+
.string()
|
|
1711
|
+
.describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123)"),
|
|
1712
|
+
},
|
|
1713
|
+
}, async ({ ticket_number }) => {
|
|
1714
|
+
const url = buildGetUrl(`/tickets/${encodeURIComponent(ticket_number)}/model-tier`, { repo_name: REPO_NAME });
|
|
1715
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
933
1716
|
const text = await handleResponse(resp);
|
|
934
1717
|
return { content: [{ type: "text", text }] };
|
|
935
1718
|
});
|
|
936
1719
|
registerTool("get_comments", {
|
|
1720
|
+
annotations: {
|
|
1721
|
+
readOnlyHint: true,
|
|
1722
|
+
destructiveHint: false,
|
|
1723
|
+
idempotentHint: true,
|
|
1724
|
+
openWorldHint: true,
|
|
1725
|
+
},
|
|
937
1726
|
description: "Retrieve all comments on a Jira ticket, oldest-first. " +
|
|
938
1727
|
"Returns an array of {id, author, body, created, updated}. " +
|
|
939
1728
|
"Comment bodies are Markdown (converted from Jira wiki markup). " +
|
|
@@ -946,11 +1735,23 @@ registerTool("get_comments", {
|
|
|
946
1735
|
},
|
|
947
1736
|
}, async ({ ticket_number }) => {
|
|
948
1737
|
const url = buildGetUrl(`/tickets/${encodeURIComponent(ticket_number)}/comments`, { repo_name: REPO_NAME });
|
|
949
|
-
const resp = await fetch(url, { headers:
|
|
950
|
-
const
|
|
1738
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
1739
|
+
const ok = resp.ok;
|
|
1740
|
+
let text = await handleResponse(resp);
|
|
1741
|
+
if (ok) {
|
|
1742
|
+
const safeTicket = safeTicketFileSegment(ticket_number);
|
|
1743
|
+
const filename = `${safeTicket}-comments.json`;
|
|
1744
|
+
text = await truncateAndSaveIfNeeded(text, await getDocsPath("comments"), filename);
|
|
1745
|
+
}
|
|
951
1746
|
return { content: [{ type: "text", text }] };
|
|
952
1747
|
});
|
|
953
1748
|
registerTool("create_ticket", {
|
|
1749
|
+
annotations: {
|
|
1750
|
+
readOnlyHint: false,
|
|
1751
|
+
destructiveHint: false,
|
|
1752
|
+
idempotentHint: false,
|
|
1753
|
+
openWorldHint: true,
|
|
1754
|
+
},
|
|
954
1755
|
description: "Create a new Jira ticket in the configured project. Requires either description or file_path (or both — file_path takes precedence). " +
|
|
955
1756
|
"Returns JSON with {ticket_key: 'PROJ-123', url: 'https://...'}. " +
|
|
956
1757
|
"The ticket is created immediately in Jira — confirm details with the user before calling. " +
|
|
@@ -999,10 +1800,17 @@ registerTool("create_ticket", {
|
|
|
999
1800
|
return { content: [{ type: "text", text: text + resolved.note }] };
|
|
1000
1801
|
});
|
|
1001
1802
|
registerTool("get_plan", {
|
|
1002
|
-
|
|
1803
|
+
annotations: {
|
|
1804
|
+
readOnlyHint: true,
|
|
1805
|
+
destructiveHint: false,
|
|
1806
|
+
idempotentHint: true,
|
|
1807
|
+
openWorldHint: true,
|
|
1808
|
+
},
|
|
1809
|
+
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. " +
|
|
1810
|
+
"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
1811
|
"Returns the full plan as markdown text — present it verbatim without summarizing. " +
|
|
1004
1812
|
"The plan includes step-by-step implementation guidance with code file references. " +
|
|
1005
|
-
"Returns 404
|
|
1813
|
+
"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
1814
|
"Tip: call get_clarifying_questions for the same ticket to get the full context for implementation.",
|
|
1007
1815
|
inputSchema: {
|
|
1008
1816
|
ticket_number: z
|
|
@@ -1015,23 +1823,21 @@ registerTool("get_plan", {
|
|
|
1015
1823
|
.describe("Whether to save the plan to a local file in the BAPI_DOCS_DIR/plans/ directory. " +
|
|
1016
1824
|
"Defaults to true. Set to false to skip saving."),
|
|
1017
1825
|
},
|
|
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 }] };
|
|
1826
|
+
}, async (args) => {
|
|
1827
|
+
return getTicketArtifact("plan", args);
|
|
1028
1828
|
});
|
|
1029
1829
|
registerTool("get_architecture", {
|
|
1030
|
-
|
|
1830
|
+
annotations: {
|
|
1831
|
+
readOnlyHint: true,
|
|
1832
|
+
destructiveHint: false,
|
|
1833
|
+
idempotentHint: true,
|
|
1834
|
+
openWorldHint: true,
|
|
1835
|
+
},
|
|
1836
|
+
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. " +
|
|
1837
|
+
"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
1838
|
"Returns the full architecture plan as markdown text — present it verbatim without summarizing. " +
|
|
1032
1839
|
"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.",
|
|
1840
|
+
"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
1841
|
inputSchema: {
|
|
1036
1842
|
ticket_number: z
|
|
1037
1843
|
.string()
|
|
@@ -1043,21 +1849,20 @@ registerTool("get_architecture", {
|
|
|
1043
1849
|
.describe("Whether to save the architecture plan to a local file in the BAPI_DOCS_DIR/architecture/ directory. " +
|
|
1044
1850
|
"Defaults to true. Set to false to skip saving."),
|
|
1045
1851
|
},
|
|
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 }] };
|
|
1852
|
+
}, async (args) => {
|
|
1853
|
+
return getTicketArtifact("architecture", args);
|
|
1056
1854
|
});
|
|
1057
1855
|
registerTool("get_clarifying_questions", {
|
|
1058
|
-
|
|
1856
|
+
annotations: {
|
|
1857
|
+
readOnlyHint: true,
|
|
1858
|
+
destructiveHint: false,
|
|
1859
|
+
idempotentHint: true,
|
|
1860
|
+
openWorldHint: true,
|
|
1861
|
+
},
|
|
1862
|
+
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. " +
|
|
1863
|
+
"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
1864
|
"Returns markdown text with questions that should be resolved before implementation begins. " +
|
|
1060
|
-
"Returns 404
|
|
1865
|
+
"Returns a 404 / not-found response when no questions are ready yet — that means generation has not run, not that this tool failed. " +
|
|
1061
1866
|
"Tip: call get_plan for the same ticket to get the implementation plan alongside these questions.",
|
|
1062
1867
|
inputSchema: {
|
|
1063
1868
|
ticket_number: z
|
|
@@ -1070,18 +1875,16 @@ registerTool("get_clarifying_questions", {
|
|
|
1070
1875
|
.describe("Whether to save the clarifying questions to a local file in the BAPI_DOCS_DIR/clarifying-questions/ directory. " +
|
|
1071
1876
|
"Defaults to true. Set to false to skip saving."),
|
|
1072
1877
|
},
|
|
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 }] };
|
|
1878
|
+
}, async (args) => {
|
|
1879
|
+
return getTicketArtifact("clarifying_questions", args);
|
|
1083
1880
|
});
|
|
1084
1881
|
registerTool("parse_repository", {
|
|
1882
|
+
annotations: {
|
|
1883
|
+
readOnlyHint: false,
|
|
1884
|
+
destructiveHint: false,
|
|
1885
|
+
idempotentHint: true,
|
|
1886
|
+
openWorldHint: true,
|
|
1887
|
+
},
|
|
1085
1888
|
description: "Queue a background job to parse and index the repository for Bridge API's AI agents. " +
|
|
1086
1889
|
"This should be run after major codebase changes so that plans and questions reflect the latest code. " +
|
|
1087
1890
|
"Returns 202 with {message: 'Repository parsing queued'} on success, " +
|
|
@@ -1101,13 +1904,19 @@ registerTool("parse_repository", {
|
|
|
1101
1904
|
payload.directory_path = directory_path;
|
|
1102
1905
|
const resp = await fetch(buildUrl("/parse-repository"), {
|
|
1103
1906
|
method: "POST",
|
|
1104
|
-
headers:
|
|
1907
|
+
headers: await getPostHeaders(),
|
|
1105
1908
|
body: JSON.stringify(payload),
|
|
1106
1909
|
});
|
|
1107
1910
|
const text = await handleResponse(resp);
|
|
1108
1911
|
return { content: [{ type: "text", text }] };
|
|
1109
1912
|
});
|
|
1110
1913
|
registerTool("regenerate_directory_map", {
|
|
1914
|
+
annotations: {
|
|
1915
|
+
readOnlyHint: false,
|
|
1916
|
+
destructiveHint: false,
|
|
1917
|
+
idempotentHint: true,
|
|
1918
|
+
openWorldHint: true,
|
|
1919
|
+
},
|
|
1111
1920
|
description: "Regenerate the repository directory map and return the result. " +
|
|
1112
1921
|
"Unlike parse_repository (which is async), this tool is synchronous — it blocks until " +
|
|
1113
1922
|
"the directory map is generated and returns the full map text directly. " +
|
|
@@ -1120,7 +1929,7 @@ registerTool("regenerate_directory_map", {
|
|
|
1120
1929
|
try {
|
|
1121
1930
|
const resp = await fetch(buildUrl("/regenerate-directory-map"), {
|
|
1122
1931
|
method: "POST",
|
|
1123
|
-
headers:
|
|
1932
|
+
headers: await getPostHeaders(),
|
|
1124
1933
|
body: JSON.stringify({ repo_name: REPO_NAME }),
|
|
1125
1934
|
signal: controller.signal,
|
|
1126
1935
|
});
|
|
@@ -1132,6 +1941,12 @@ registerTool("regenerate_directory_map", {
|
|
|
1132
1941
|
}
|
|
1133
1942
|
});
|
|
1134
1943
|
registerTool("get_parse_status", {
|
|
1944
|
+
annotations: {
|
|
1945
|
+
readOnlyHint: true,
|
|
1946
|
+
destructiveHint: false,
|
|
1947
|
+
idempotentHint: true,
|
|
1948
|
+
openWorldHint: true,
|
|
1949
|
+
},
|
|
1135
1950
|
description: "Check whether a repository parse job is currently running. " +
|
|
1136
1951
|
"Returns {status: 'in_progress', started_at: '<ISO timestamp>'} if a parse is active, " +
|
|
1137
1952
|
"or {status: 'idle'} if no parse is running. " +
|
|
@@ -1140,11 +1955,17 @@ registerTool("get_parse_status", {
|
|
|
1140
1955
|
inputSchema: {},
|
|
1141
1956
|
}, async () => {
|
|
1142
1957
|
const url = buildGetUrl("/parse-status", { repo_name: REPO_NAME });
|
|
1143
|
-
const resp = await fetch(url, { headers:
|
|
1958
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
1144
1959
|
const text = await handleResponse(resp);
|
|
1145
1960
|
return { content: [{ type: "text", text }] };
|
|
1146
1961
|
});
|
|
1147
1962
|
registerTool("add_comment", {
|
|
1963
|
+
annotations: {
|
|
1964
|
+
readOnlyHint: false,
|
|
1965
|
+
destructiveHint: false,
|
|
1966
|
+
idempotentHint: false,
|
|
1967
|
+
openWorldHint: true,
|
|
1968
|
+
},
|
|
1148
1969
|
description: "Post a comment on a Jira ticket. The comment appears immediately in Jira. " +
|
|
1149
1970
|
"Supports markdown formatting. " +
|
|
1150
1971
|
"For long comments (over ~2000 characters), set attach_as_file to true — " +
|
|
@@ -1192,13 +2013,19 @@ registerTool("add_comment", {
|
|
|
1192
2013
|
}
|
|
1193
2014
|
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/comment`), {
|
|
1194
2015
|
method: "POST",
|
|
1195
|
-
headers:
|
|
2016
|
+
headers: await getPostHeaders(),
|
|
1196
2017
|
body: JSON.stringify(payload),
|
|
1197
2018
|
});
|
|
1198
2019
|
const text = await handleResponse(resp);
|
|
1199
2020
|
return { content: [{ type: "text", text: text + resolved.note }] };
|
|
1200
2021
|
});
|
|
1201
2022
|
registerTool("update_ticket_description", {
|
|
2023
|
+
annotations: {
|
|
2024
|
+
readOnlyHint: false,
|
|
2025
|
+
destructiveHint: true,
|
|
2026
|
+
idempotentHint: true,
|
|
2027
|
+
openWorldHint: true,
|
|
2028
|
+
},
|
|
1202
2029
|
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
2030
|
"The description should be in markdown format — it will be automatically converted to Jira wiki markup. " +
|
|
1204
2031
|
"This does NOT create a new ticket. Use create_ticket for that. " +
|
|
@@ -1224,19 +2051,25 @@ registerTool("update_ticket_description", {
|
|
|
1224
2051
|
return resolved.errorResponse;
|
|
1225
2052
|
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/description`), {
|
|
1226
2053
|
method: "PUT",
|
|
1227
|
-
headers:
|
|
2054
|
+
headers: await getPostHeaders(),
|
|
1228
2055
|
body: JSON.stringify({ repo_name: REPO_NAME, description: resolved.text }),
|
|
1229
2056
|
});
|
|
1230
2057
|
const text = await handleResponse(resp);
|
|
1231
2058
|
return { content: [{ type: "text", text: text + resolved.note }] };
|
|
1232
2059
|
});
|
|
1233
2060
|
registerTool("upload_attachment", {
|
|
2061
|
+
annotations: {
|
|
2062
|
+
readOnlyHint: false,
|
|
2063
|
+
destructiveHint: false,
|
|
2064
|
+
idempotentHint: false,
|
|
2065
|
+
openWorldHint: true,
|
|
2066
|
+
},
|
|
1234
2067
|
description: "Upload a local file as an attachment to a Jira ticket. " +
|
|
1235
2068
|
"Supports text/UTF-8 files only (markdown, plain text, etc.). " +
|
|
1236
2069
|
"Optionally syncs the content to Bridge API's tickets_links table so retrieval endpoints " +
|
|
1237
2070
|
"(get_clarifying_questions, get_ticket_critique, get_plan) return the updated content without re-generation. " +
|
|
1238
2071
|
"Use link_type to specify which retrieval endpoint should serve this content. " +
|
|
1239
|
-
"Known link_type values: clarifying-questions.md, debugging-guidance.md, ticket-quality-critique.md, architecture-plan.md.",
|
|
2072
|
+
"Known link_type values: clarifying-questions.md, debugging-guidance.md, ticket-quality-critique.md, architecture-plan.md, fsd-plan.md.",
|
|
1240
2073
|
inputSchema: {
|
|
1241
2074
|
ticket_number: z
|
|
1242
2075
|
.string()
|
|
@@ -1287,13 +2120,19 @@ registerTool("upload_attachment", {
|
|
|
1287
2120
|
}
|
|
1288
2121
|
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/attachment`), {
|
|
1289
2122
|
method: "POST",
|
|
1290
|
-
headers:
|
|
2123
|
+
headers: await getPostHeaders(),
|
|
1291
2124
|
body: JSON.stringify(payload),
|
|
1292
2125
|
});
|
|
1293
2126
|
const text = await handleResponse(resp);
|
|
1294
2127
|
return { content: [{ type: "text", text: text + resolved.note }] };
|
|
1295
2128
|
});
|
|
1296
2129
|
registerTool("list_attachments", {
|
|
2130
|
+
annotations: {
|
|
2131
|
+
readOnlyHint: true,
|
|
2132
|
+
destructiveHint: false,
|
|
2133
|
+
idempotentHint: true,
|
|
2134
|
+
openWorldHint: true,
|
|
2135
|
+
},
|
|
1297
2136
|
description: "List attachments on a Jira ticket. " +
|
|
1298
2137
|
"Returns metadata (ID, filename, MIME type, size, created date) for each attachment. " +
|
|
1299
2138
|
"By default, AI-generated attachments are excluded. " +
|
|
@@ -1313,12 +2152,23 @@ registerTool("list_attachments", {
|
|
|
1313
2152
|
params.include_ai_generated = "true";
|
|
1314
2153
|
}
|
|
1315
2154
|
const url = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/attachments`, params);
|
|
1316
|
-
const resp = await fetch(url, { headers:
|
|
1317
|
-
const
|
|
2155
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
2156
|
+
const ok = resp.ok;
|
|
2157
|
+
let text = await handleResponse(resp);
|
|
2158
|
+
if (ok) {
|
|
2159
|
+
const safeTicket = safeTicketFileSegment(ticket_number);
|
|
2160
|
+
const filename = `${safeTicket}-attachment-list.json`;
|
|
2161
|
+
text = await truncateAndSaveIfNeeded(text, await getDocsPath("attachments"), filename);
|
|
2162
|
+
}
|
|
1318
2163
|
return { content: [{ type: "text", text }] };
|
|
1319
2164
|
});
|
|
1320
|
-
const MAX_INLINE_TEXT_LENGTH = 50_000;
|
|
1321
2165
|
registerTool("download_attachment", {
|
|
2166
|
+
annotations: {
|
|
2167
|
+
readOnlyHint: true,
|
|
2168
|
+
destructiveHint: false,
|
|
2169
|
+
idempotentHint: true,
|
|
2170
|
+
openWorldHint: true,
|
|
2171
|
+
},
|
|
1322
2172
|
description: "Download an attachment from a Jira ticket and save it to disk. " +
|
|
1323
2173
|
"Specify either attachment_id or filename (not both). " +
|
|
1324
2174
|
"For text files, the content is returned inline (truncated at ~50KB) and also saved to disk. " +
|
|
@@ -1359,7 +2209,7 @@ registerTool("download_attachment", {
|
|
|
1359
2209
|
if (filename)
|
|
1360
2210
|
params.filename = filename;
|
|
1361
2211
|
const url = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/attachments/download`, params);
|
|
1362
|
-
const resp = await fetch(url, { headers:
|
|
2212
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
1363
2213
|
if (!resp.ok) {
|
|
1364
2214
|
const text = await handleResponse(resp);
|
|
1365
2215
|
return { content: [{ type: "text", text }] };
|
|
@@ -1374,9 +2224,9 @@ registerTool("download_attachment", {
|
|
|
1374
2224
|
const safeTicket = path.basename(ticket_number);
|
|
1375
2225
|
const savePath = file_path
|
|
1376
2226
|
? file_path
|
|
1377
|
-
: path.join(
|
|
2227
|
+
: path.join(await getDocsDir(), "attachments", safeTicket, safeFileName);
|
|
1378
2228
|
const resolvedSave = path.resolve(savePath);
|
|
1379
|
-
const resolvedRoot = path.resolve(
|
|
2229
|
+
const resolvedRoot = path.resolve(await getProjectRoot());
|
|
1380
2230
|
if (!resolvedSave.startsWith(resolvedRoot + path.sep) && resolvedSave !== resolvedRoot) {
|
|
1381
2231
|
return {
|
|
1382
2232
|
content: [{
|
|
@@ -1407,10 +2257,16 @@ registerTool("download_attachment", {
|
|
|
1407
2257
|
return { content: [{ type: "text", text: resultText }] };
|
|
1408
2258
|
});
|
|
1409
2259
|
registerTool("request_plan_generation", {
|
|
1410
|
-
|
|
2260
|
+
annotations: {
|
|
2261
|
+
readOnlyHint: false,
|
|
2262
|
+
destructiveHint: false,
|
|
2263
|
+
idempotentHint: false,
|
|
2264
|
+
openWorldHint: true,
|
|
2265
|
+
},
|
|
2266
|
+
description: "START (or refresh) async generation of an implementation plan for a Jira ticket. " +
|
|
1411
2267
|
"This triggers an asynchronous background job — results are NOT immediate. " +
|
|
1412
2268
|
"Processing typically takes 1-5 minutes depending on ticket complexity and number of attachments. " +
|
|
1413
|
-
"
|
|
2269
|
+
"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
2270
|
"Returns 202 if the request was accepted, 404 if the ticket does not exist in Jira, " +
|
|
1415
2271
|
"or 403 if the API key is unauthorized. " +
|
|
1416
2272
|
"Set wait_for_result to true to block until the result is ready (typically 1-5 minutes) instead of returning immediately.",
|
|
@@ -1431,55 +2287,34 @@ registerTool("request_plan_generation", {
|
|
|
1431
2287
|
.default(true)
|
|
1432
2288
|
.describe("When wait_for_result is true, whether to save the plan to a local file in the " +
|
|
1433
2289
|
"BAPI_DOCS_DIR/plans/ directory. Defaults to true. Ignored when wait_for_result is false."),
|
|
1434
|
-
second_opinion: z
|
|
2290
|
+
second_opinion: z
|
|
2291
|
+
.string()
|
|
2292
|
+
.optional()
|
|
2293
|
+
.describe("Provider routing override for THIS artifact-generation request " +
|
|
2294
|
+
"(e.g. 'anthropic', 'openai', 'gemini'). When set, the artifact is " +
|
|
2295
|
+
"generated by the named provider and, where supported, a cross-provider " +
|
|
2296
|
+
"second-opinion pass is applied to this request only. " +
|
|
2297
|
+
"This is NOT the standalone `second_opinion` tool — it does not return " +
|
|
2298
|
+
"an ad hoc critique; it only changes which provider produces this " +
|
|
2299
|
+
"request's artifact. Takes precedence over `provider` when both are set."),
|
|
1435
2300
|
provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
|
|
1436
2301
|
"triggering second-opinion semantics. If both provider and second_opinion are set, " +
|
|
1437
2302
|
"second_opinion takes precedence."),
|
|
1438
2303
|
},
|
|
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 }] };
|
|
2304
|
+
}, async (args) => {
|
|
2305
|
+
return requestTicketArtifact("plan", args);
|
|
1477
2306
|
});
|
|
1478
2307
|
registerTool("request_architecture", {
|
|
1479
|
-
|
|
2308
|
+
annotations: {
|
|
2309
|
+
readOnlyHint: false,
|
|
2310
|
+
destructiveHint: false,
|
|
2311
|
+
idempotentHint: false,
|
|
2312
|
+
openWorldHint: true,
|
|
2313
|
+
},
|
|
2314
|
+
description: "START (or refresh) async generation of an architecture plan for a Jira ticket. " +
|
|
1480
2315
|
"This triggers an asynchronous background job — results are NOT immediate. " +
|
|
1481
2316
|
"Processing typically takes 2-4 minutes depending on ticket complexity. " +
|
|
1482
|
-
"
|
|
2317
|
+
"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
2318
|
"Returns 202 if the request was accepted, 404 if the ticket does not exist in Jira, " +
|
|
1484
2319
|
"or 403 if the API key is unauthorized. " +
|
|
1485
2320
|
"Set wait_for_result to true to block until the result is ready (typically 2-4 minutes) instead of returning immediately.",
|
|
@@ -1500,55 +2335,115 @@ registerTool("request_architecture", {
|
|
|
1500
2335
|
.default(true)
|
|
1501
2336
|
.describe("When wait_for_result is true, whether to save the architecture plan to a local file in the " +
|
|
1502
2337
|
"BAPI_DOCS_DIR/architecture/ directory. Defaults to true. Only takes effect when wait_for_result is true."),
|
|
1503
|
-
second_opinion: z
|
|
2338
|
+
second_opinion: z
|
|
2339
|
+
.string()
|
|
2340
|
+
.optional()
|
|
2341
|
+
.describe("Provider routing override for THIS artifact-generation request " +
|
|
2342
|
+
"(e.g. 'anthropic', 'openai', 'gemini'). When set, the artifact is " +
|
|
2343
|
+
"generated by the named provider and, where supported, a cross-provider " +
|
|
2344
|
+
"second-opinion pass is applied to this request only. " +
|
|
2345
|
+
"This is NOT the standalone `second_opinion` tool — it does not return " +
|
|
2346
|
+
"an ad hoc critique; it only changes which provider produces this " +
|
|
2347
|
+
"request's artifact. Takes precedence over `provider` when both are set."),
|
|
1504
2348
|
provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
|
|
1505
2349
|
"triggering second-opinion semantics. If both provider and second_opinion are set, " +
|
|
1506
2350
|
"second_opinion takes precedence."),
|
|
1507
2351
|
},
|
|
1508
|
-
}, async (
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
2352
|
+
}, async (args) => {
|
|
2353
|
+
return requestTicketArtifact("architecture", args);
|
|
2354
|
+
});
|
|
2355
|
+
registerTool("request_design_doc", {
|
|
2356
|
+
annotations: {
|
|
2357
|
+
readOnlyHint: false,
|
|
2358
|
+
destructiveHint: false,
|
|
2359
|
+
idempotentHint: false,
|
|
2360
|
+
openWorldHint: true,
|
|
2361
|
+
},
|
|
2362
|
+
description: "START (or refresh) async generation of a design document for a Jira ticket, routed by doc_type. " +
|
|
2363
|
+
"Use doc_type 'tdd' for a Technical Design Document (architecture-focused, for engineers) or 'fsd' for a " +
|
|
2364
|
+
"Functional Specification Document (product/functional-focused, for PMs, designers, and QA). " +
|
|
2365
|
+
"This triggers an asynchronous background job — results are NOT immediate. " +
|
|
2366
|
+
"Processing typically takes 2-4 minutes depending on ticket complexity. " +
|
|
2367
|
+
"The matching get_design_doc tool retrieves the generated document later (call get_design_doc with the same ticket_number and doc_type) — unless you set wait_for_result, in which case this call blocks and returns it directly. " +
|
|
2368
|
+
"Returns 202 if the request was accepted, 404 if the ticket does not exist in Jira, or 403 if the API key is unauthorized.",
|
|
2369
|
+
inputSchema: {
|
|
2370
|
+
ticket_number: z
|
|
2371
|
+
.string()
|
|
2372
|
+
.describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123) to generate a design document for"),
|
|
2373
|
+
doc_type: z
|
|
2374
|
+
.enum(["tdd", "fsd"])
|
|
2375
|
+
.describe("Which design document to generate: 'tdd' (Technical Design Document, engineer audience) or " +
|
|
2376
|
+
"'fsd' (Functional Specification Document, product/functional audience)."),
|
|
2377
|
+
wait_for_result: z
|
|
2378
|
+
.boolean()
|
|
2379
|
+
.optional()
|
|
2380
|
+
.default(false)
|
|
2381
|
+
.describe("When true, the tool blocks and polls until the document is ready (typically 2-4 minutes), " +
|
|
2382
|
+
"then returns the full content directly. When false (default), returns immediately " +
|
|
2383
|
+
"with a confirmation message — use get_design_doc later to retrieve results."),
|
|
2384
|
+
save_locally: z
|
|
2385
|
+
.boolean()
|
|
2386
|
+
.optional()
|
|
2387
|
+
.default(true)
|
|
2388
|
+
.describe("When wait_for_result is true, whether to save the generated document to a local file under " +
|
|
2389
|
+
"BAPI_DOCS_DIR. Defaults to true. Only takes effect when wait_for_result is true."),
|
|
2390
|
+
second_opinion: z
|
|
2391
|
+
.string()
|
|
2392
|
+
.optional()
|
|
2393
|
+
.describe("Provider routing override for THIS artifact-generation request " +
|
|
2394
|
+
"(e.g. 'anthropic', 'openai', 'gemini'). When set, the artifact is " +
|
|
2395
|
+
"generated by the named provider and, where supported, a cross-provider " +
|
|
2396
|
+
"second-opinion pass is applied to this request only. " +
|
|
2397
|
+
"Takes precedence over `provider` when both are set."),
|
|
2398
|
+
provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
|
|
2399
|
+
"triggering second-opinion semantics. If both provider and second_opinion are set, " +
|
|
2400
|
+
"second_opinion takes precedence."),
|
|
2401
|
+
},
|
|
2402
|
+
}, async (args) => {
|
|
2403
|
+
return requestTicketArtifact(resolveDesignDocArtifactType(args.doc_type), args);
|
|
2404
|
+
});
|
|
2405
|
+
registerTool("get_design_doc", {
|
|
2406
|
+
annotations: {
|
|
2407
|
+
readOnlyHint: true,
|
|
2408
|
+
destructiveHint: false,
|
|
2409
|
+
idempotentHint: true,
|
|
2410
|
+
openWorldHint: true,
|
|
2411
|
+
},
|
|
2412
|
+
description: "RETRIEVE an already-generated design document for a Jira ticket, routed by doc_type. " +
|
|
2413
|
+
"Use doc_type 'tdd' for the Technical Design Document or 'fsd' for the Functional Specification Document. " +
|
|
2414
|
+
"This tool only fetches an existing document — it does NOT start or trigger generation. " +
|
|
2415
|
+
"If no document exists yet (or you need a fresh one), call `request_design_doc` first with the same doc_type. " +
|
|
2416
|
+
"Returns the full document as markdown text — present it verbatim without summarizing. " +
|
|
2417
|
+
"Returns a 404 / not-found response when no document is ready yet — that means generation has not run, not that this tool failed.",
|
|
2418
|
+
inputSchema: {
|
|
2419
|
+
ticket_number: z
|
|
2420
|
+
.string()
|
|
2421
|
+
.describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123, BAPI-42)"),
|
|
2422
|
+
doc_type: z
|
|
2423
|
+
.enum(["tdd", "fsd"])
|
|
2424
|
+
.describe("Which design document to retrieve: 'tdd' (Technical Design Document) or " +
|
|
2425
|
+
"'fsd' (Functional Specification Document)."),
|
|
2426
|
+
save_locally: z
|
|
2427
|
+
.boolean()
|
|
2428
|
+
.optional()
|
|
2429
|
+
.default(true)
|
|
2430
|
+
.describe("Whether to save the retrieved document to a local file under BAPI_DOCS_DIR. " +
|
|
2431
|
+
"Defaults to true. Set to false to skip saving."),
|
|
2432
|
+
},
|
|
2433
|
+
}, async (args) => {
|
|
2434
|
+
return getTicketArtifact(resolveDesignDocArtifactType(args.doc_type), args);
|
|
1546
2435
|
});
|
|
1547
2436
|
registerTool("request_clarifying_questions", {
|
|
1548
|
-
|
|
2437
|
+
annotations: {
|
|
2438
|
+
readOnlyHint: false,
|
|
2439
|
+
destructiveHint: false,
|
|
2440
|
+
idempotentHint: false,
|
|
2441
|
+
openWorldHint: true,
|
|
2442
|
+
},
|
|
2443
|
+
description: "START (or refresh) async generation of clarifying questions or debugging guidance for a Jira ticket. " +
|
|
1549
2444
|
"This triggers an asynchronous background job — results are NOT immediate. " +
|
|
1550
2445
|
"Processing typically takes 1-5 minutes. " +
|
|
1551
|
-
"
|
|
2446
|
+
"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
2447
|
"For bug tickets, the result may be debugging guidance instead of clarifying questions. " +
|
|
1553
2448
|
"Returns 202 if the request was accepted, 404 if the ticket does not exist in Jira, " +
|
|
1554
2449
|
"or 403 if the API key is unauthorized. " +
|
|
@@ -1570,59 +2465,38 @@ registerTool("request_clarifying_questions", {
|
|
|
1570
2465
|
.default(true)
|
|
1571
2466
|
.describe("When wait_for_result is true, whether to save the clarifying questions to a local file in the " +
|
|
1572
2467
|
"BAPI_DOCS_DIR/clarifying-questions/ directory. Defaults to true. Ignored when wait_for_result is false."),
|
|
1573
|
-
second_opinion: z
|
|
2468
|
+
second_opinion: z
|
|
2469
|
+
.string()
|
|
2470
|
+
.optional()
|
|
2471
|
+
.describe("Provider routing override for THIS artifact-generation request " +
|
|
2472
|
+
"(e.g. 'anthropic', 'openai', 'gemini'). When set, the artifact is " +
|
|
2473
|
+
"generated by the named provider and, where supported, a cross-provider " +
|
|
2474
|
+
"second-opinion pass is applied to this request only. " +
|
|
2475
|
+
"This is NOT the standalone `second_opinion` tool — it does not return " +
|
|
2476
|
+
"an ad hoc critique; it only changes which provider produces this " +
|
|
2477
|
+
"request's artifact. Takes precedence over `provider` when both are set."),
|
|
1574
2478
|
provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
|
|
1575
2479
|
"triggering second-opinion semantics. If both provider and second_opinion are set, " +
|
|
1576
2480
|
"second_opinion takes precedence."),
|
|
1577
2481
|
},
|
|
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 }] };
|
|
2482
|
+
}, async (args) => {
|
|
2483
|
+
return requestTicketArtifact("clarifying_questions", args);
|
|
1616
2484
|
});
|
|
1617
2485
|
// ---------------------------------------------------------------------------
|
|
1618
2486
|
// Ticket Quality Critique
|
|
1619
2487
|
// ---------------------------------------------------------------------------
|
|
1620
2488
|
registerTool("get_ticket_critique", {
|
|
1621
|
-
|
|
2489
|
+
annotations: {
|
|
2490
|
+
readOnlyHint: true,
|
|
2491
|
+
destructiveHint: false,
|
|
2492
|
+
idempotentHint: true,
|
|
2493
|
+
openWorldHint: true,
|
|
2494
|
+
},
|
|
2495
|
+
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. " +
|
|
2496
|
+
"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
2497
|
"Returns markdown text with a structured critique covering Standards Conformance Analysis, " +
|
|
1623
2498
|
"Standards Deviations, and Suggested Improvements. " +
|
|
1624
|
-
"Returns 404
|
|
1625
|
-
"Tip: use request_ticket_critique to trigger critique generation first.",
|
|
2499
|
+
"Returns a 404 / not-found response when no critique is ready yet — that means generation has not run, not that this tool failed.",
|
|
1626
2500
|
inputSchema: {
|
|
1627
2501
|
ticket_number: z
|
|
1628
2502
|
.string()
|
|
@@ -1634,22 +2508,20 @@ registerTool("get_ticket_critique", {
|
|
|
1634
2508
|
.describe("Whether to save the critique to a local file in the BAPI_DOCS_DIR/ticket-critiques/ directory. " +
|
|
1635
2509
|
"Defaults to true. Set to false to skip saving."),
|
|
1636
2510
|
},
|
|
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 }] };
|
|
2511
|
+
}, async (args) => {
|
|
2512
|
+
return getTicketArtifact("ticket_critique", args);
|
|
1647
2513
|
});
|
|
1648
2514
|
registerTool("request_ticket_critique", {
|
|
1649
|
-
|
|
2515
|
+
annotations: {
|
|
2516
|
+
readOnlyHint: false,
|
|
2517
|
+
destructiveHint: false,
|
|
2518
|
+
idempotentHint: false,
|
|
2519
|
+
openWorldHint: true,
|
|
2520
|
+
},
|
|
2521
|
+
description: "START (or refresh) async generation of a ticket critique for a Jira ticket. " +
|
|
1650
2522
|
"This triggers an asynchronous background job — results are NOT immediate. " +
|
|
1651
2523
|
"Processing typically takes 1-5 minutes. " +
|
|
1652
|
-
"
|
|
2524
|
+
"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
2525
|
"Returns 202 if the request was accepted, 404 if the ticket does not exist in Jira, " +
|
|
1654
2526
|
"or 403 if the API key is unauthorized. " +
|
|
1655
2527
|
"Set wait_for_result to true to block until the result is ready (typically 1-5 minutes) instead of returning immediately.",
|
|
@@ -1670,54 +2542,33 @@ registerTool("request_ticket_critique", {
|
|
|
1670
2542
|
.default(true)
|
|
1671
2543
|
.describe("When wait_for_result is true, whether to save the ticket critique to a local file in the " +
|
|
1672
2544
|
"BAPI_DOCS_DIR/ticket-critiques/ directory. Defaults to true. Ignored when wait_for_result is false."),
|
|
1673
|
-
second_opinion: z
|
|
2545
|
+
second_opinion: z
|
|
2546
|
+
.string()
|
|
2547
|
+
.optional()
|
|
2548
|
+
.describe("Provider routing override for THIS artifact-generation request " +
|
|
2549
|
+
"(e.g. 'anthropic', 'openai', 'gemini'). When set, the artifact is " +
|
|
2550
|
+
"generated by the named provider and, where supported, a cross-provider " +
|
|
2551
|
+
"second-opinion pass is applied to this request only. " +
|
|
2552
|
+
"This is NOT the standalone `second_opinion` tool — it does not return " +
|
|
2553
|
+
"an ad hoc critique; it only changes which provider produces this " +
|
|
2554
|
+
"request's artifact. Takes precedence over `provider` when both are set."),
|
|
1674
2555
|
provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
|
|
1675
2556
|
"triggering second-opinion semantics. If both provider and second_opinion are set, " +
|
|
1676
2557
|
"second_opinion takes precedence."),
|
|
1677
2558
|
},
|
|
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 }] };
|
|
2559
|
+
}, async (args) => {
|
|
2560
|
+
return requestTicketArtifact("ticket_critique", args);
|
|
1716
2561
|
});
|
|
1717
2562
|
// ---------------------------------------------------------------------------
|
|
1718
2563
|
// Combined Ticket Review (clarify + critique)
|
|
1719
2564
|
// ---------------------------------------------------------------------------
|
|
1720
2565
|
registerTool("request_ticket_review", {
|
|
2566
|
+
annotations: {
|
|
2567
|
+
readOnlyHint: false,
|
|
2568
|
+
destructiveHint: false,
|
|
2569
|
+
idempotentHint: false,
|
|
2570
|
+
openWorldHint: true,
|
|
2571
|
+
},
|
|
1721
2572
|
description: "Request a combined ticket review that generates BOTH clarifying questions (or debugging guidance for bug tickets) " +
|
|
1722
2573
|
"AND a ticket quality critique in parallel on the server, halving wall-clock latency vs. running the two " +
|
|
1723
2574
|
"requests sequentially. This triggers an asynchronous background job — results are NOT immediate. " +
|
|
@@ -1742,93 +2593,37 @@ registerTool("request_ticket_review", {
|
|
|
1742
2593
|
.default(true)
|
|
1743
2594
|
.describe("When wait_for_result is true, whether to save each generated document to its canonical local path under " +
|
|
1744
2595
|
"BAPI_DOCS_DIR (clarifying-questions/ or ticket-critiques/). Defaults to true. Ignored when wait_for_result is false."),
|
|
1745
|
-
second_opinion: z
|
|
2596
|
+
second_opinion: z
|
|
2597
|
+
.string()
|
|
2598
|
+
.optional()
|
|
2599
|
+
.describe("Provider routing override for THIS artifact-generation request " +
|
|
2600
|
+
"(e.g. 'anthropic', 'openai', 'gemini'). When set, the artifact is " +
|
|
2601
|
+
"generated by the named provider and, where supported, a cross-provider " +
|
|
2602
|
+
"second-opinion pass is applied to this request only. " +
|
|
2603
|
+
"This is NOT the standalone `second_opinion` tool — it does not return " +
|
|
2604
|
+
"an ad hoc critique; it only changes which provider produces this " +
|
|
2605
|
+
"request's artifact. Takes precedence over `provider` when both are set."),
|
|
1746
2606
|
provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
|
|
1747
2607
|
"triggering second-opinion semantics. If both provider and second_opinion are set, " +
|
|
1748
2608
|
"second_opinion takes precedence."),
|
|
1749
2609
|
},
|
|
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 }] };
|
|
2610
|
+
}, async (args) => {
|
|
2611
|
+
return requestTicketReview(args);
|
|
1823
2612
|
});
|
|
1824
2613
|
// ---------------------------------------------------------------------------
|
|
1825
2614
|
// Reimplement Context
|
|
1826
2615
|
// ---------------------------------------------------------------------------
|
|
1827
2616
|
registerTool("request_reimplement_context", {
|
|
1828
|
-
|
|
2617
|
+
annotations: {
|
|
2618
|
+
readOnlyHint: false,
|
|
2619
|
+
destructiveHint: false,
|
|
2620
|
+
idempotentHint: false,
|
|
2621
|
+
openWorldHint: true,
|
|
2622
|
+
},
|
|
2623
|
+
description: "START async processing of new attachments and context assembly for a previously-implemented Jira ticket. " +
|
|
1829
2624
|
"Use this for follow-up requests on tickets that have already been through the plan+implement cycle. " +
|
|
1830
2625
|
"This triggers an asynchronous background job to process new attachments/images. " +
|
|
1831
|
-
"
|
|
2626
|
+
"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
2627
|
"Set wait_for_result to true to block until the context is ready instead of returning immediately.",
|
|
1833
2628
|
inputSchema: {
|
|
1834
2629
|
ticket_number: z
|
|
@@ -1846,56 +2641,35 @@ registerTool("request_reimplement_context", {
|
|
|
1846
2641
|
.default(true)
|
|
1847
2642
|
.describe("When wait_for_result is true, save the context markdown to " +
|
|
1848
2643
|
"BAPI_DOCS_DIR/reimplementations/{ticket_number}-context.md. Defaults to true."),
|
|
1849
|
-
second_opinion: z
|
|
2644
|
+
second_opinion: z
|
|
2645
|
+
.string()
|
|
2646
|
+
.optional()
|
|
2647
|
+
.describe("Provider routing override for THIS artifact-generation request " +
|
|
2648
|
+
"(e.g. 'anthropic', 'openai', 'gemini'). When set, the artifact is " +
|
|
2649
|
+
"generated by the named provider and, where supported, a cross-provider " +
|
|
2650
|
+
"second-opinion pass is applied to this request only. " +
|
|
2651
|
+
"This is NOT the standalone `second_opinion` tool — it does not return " +
|
|
2652
|
+
"an ad hoc critique; it only changes which provider produces this " +
|
|
2653
|
+
"request's artifact. Takes precedence over `provider` when both are set."),
|
|
1850
2654
|
provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
|
|
1851
2655
|
"triggering second-opinion semantics. If both provider and second_opinion are set, " +
|
|
1852
2656
|
"second_opinion takes precedence."),
|
|
1853
2657
|
},
|
|
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 }] };
|
|
2658
|
+
}, async (args) => {
|
|
2659
|
+
return requestTicketArtifact("reimplement_context", args);
|
|
1892
2660
|
});
|
|
1893
2661
|
registerTool("get_reimplement_context", {
|
|
1894
|
-
|
|
2662
|
+
annotations: {
|
|
2663
|
+
readOnlyHint: true,
|
|
2664
|
+
destructiveHint: false,
|
|
2665
|
+
idempotentHint: true,
|
|
2666
|
+
openWorldHint: true,
|
|
2667
|
+
},
|
|
2668
|
+
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. " +
|
|
2669
|
+
"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
2670
|
"Returns a markdown document with new/changed information diffed against stored state, " +
|
|
1896
2671
|
"the original ticket description, and the existing implementation plan. " +
|
|
1897
|
-
"Returns 404
|
|
1898
|
-
"Tip: use request_reimplement_context to trigger processing first.",
|
|
2672
|
+
"Returns a 404 / not-found response when processing is not yet complete — that means processing has not finished, not that this tool failed.",
|
|
1899
2673
|
inputSchema: {
|
|
1900
2674
|
ticket_number: z
|
|
1901
2675
|
.string()
|
|
@@ -1906,35 +2680,24 @@ registerTool("get_reimplement_context", {
|
|
|
1906
2680
|
.default(true)
|
|
1907
2681
|
.describe("Save the context to BAPI_DOCS_DIR/reimplementations/{ticket_number}-context.md. Defaults to true."),
|
|
1908
2682
|
},
|
|
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 }] };
|
|
2683
|
+
}, async (args) => {
|
|
2684
|
+
return getTicketArtifact("reimplement_context", args);
|
|
1930
2685
|
});
|
|
1931
2686
|
// ---------------------------------------------------------------------------
|
|
1932
2687
|
// Ticket Lifecycle Tracking
|
|
1933
2688
|
// ---------------------------------------------------------------------------
|
|
1934
2689
|
registerTool("track_ticket", {
|
|
1935
|
-
|
|
2690
|
+
annotations: {
|
|
2691
|
+
readOnlyHint: false,
|
|
2692
|
+
destructiveHint: false,
|
|
2693
|
+
idempotentHint: true,
|
|
2694
|
+
openWorldHint: true,
|
|
2695
|
+
},
|
|
2696
|
+
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. " +
|
|
2697
|
+
"It does NOT edit anything in Jira: it does not change the Jira summary, description, comments, attachments, or status. " +
|
|
1936
2698
|
"If the ticket is already tracked, this is a safe no-op — it upserts the description and repo_name without error. " +
|
|
1937
|
-
"
|
|
2699
|
+
"After create_ticket, this is the correct next step when you want Bridge to track that ticket's workflow timestamps / artifact state. " +
|
|
2700
|
+
"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
2701
|
"The repo_name is automatically injected from the configured environment.",
|
|
1939
2702
|
inputSchema: {
|
|
1940
2703
|
ticket_number: z.string().describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123)"),
|
|
@@ -1946,13 +2709,19 @@ registerTool("track_ticket", {
|
|
|
1946
2709
|
payload.description = description;
|
|
1947
2710
|
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/track`), {
|
|
1948
2711
|
method: "POST",
|
|
1949
|
-
headers:
|
|
2712
|
+
headers: await getPostHeaders(),
|
|
1950
2713
|
body: JSON.stringify(payload),
|
|
1951
2714
|
});
|
|
1952
2715
|
const text = await handleResponse(resp);
|
|
1953
2716
|
return { content: [{ type: "text", text }] };
|
|
1954
2717
|
});
|
|
1955
2718
|
registerTool("update_ticket_state", {
|
|
2719
|
+
annotations: {
|
|
2720
|
+
readOnlyHint: false,
|
|
2721
|
+
destructiveHint: false,
|
|
2722
|
+
idempotentHint: false,
|
|
2723
|
+
openWorldHint: true,
|
|
2724
|
+
},
|
|
1956
2725
|
description: "Update workflow state timestamps on a tracked ticket. Each specified field is set to the current UTC timestamp on the server. " +
|
|
1957
2726
|
"Valid field names: 'critique_called', 'critique_answered', 'clarify_called', 'clarify_answered', 'plan_generated', 'implemented', 'reimplement_called'. " +
|
|
1958
2727
|
"The ticket must already be tracked (via track_ticket) or a 404 error is returned. " +
|
|
@@ -1966,13 +2735,19 @@ registerTool("update_ticket_state", {
|
|
|
1966
2735
|
const payload = { repo_name: REPO_NAME, fields };
|
|
1967
2736
|
const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/state`), {
|
|
1968
2737
|
method: "PUT",
|
|
1969
|
-
headers:
|
|
2738
|
+
headers: await getPostHeaders(),
|
|
1970
2739
|
body: JSON.stringify(payload),
|
|
1971
2740
|
});
|
|
1972
2741
|
const text = await handleResponse(resp);
|
|
1973
2742
|
return { content: [{ type: "text", text }] };
|
|
1974
2743
|
});
|
|
1975
2744
|
registerTool("get_ticket_state", {
|
|
2745
|
+
annotations: {
|
|
2746
|
+
readOnlyHint: true,
|
|
2747
|
+
destructiveHint: false,
|
|
2748
|
+
idempotentHint: true,
|
|
2749
|
+
openWorldHint: true,
|
|
2750
|
+
},
|
|
1976
2751
|
description: "Retrieve workflow state timestamps and artifact existence flags for a tracked ticket. " +
|
|
1977
2752
|
"Returns timestamps for each state field (critique_called, critique_answered, clarify_called, clarify_answered, plan_generated, implemented, reimplement_called) " +
|
|
1978
2753
|
"and boolean flags indicating whether artifacts exist (has_clarifying_questions, has_critique, has_plan). " +
|
|
@@ -1983,7 +2758,7 @@ registerTool("get_ticket_state", {
|
|
|
1983
2758
|
},
|
|
1984
2759
|
}, async ({ ticket_number }) => {
|
|
1985
2760
|
const url = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/state`, { repo_name: REPO_NAME });
|
|
1986
|
-
const resp = await fetch(url, { headers:
|
|
2761
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
1987
2762
|
const text = await handleResponse(resp);
|
|
1988
2763
|
return { content: [{ type: "text", text }] };
|
|
1989
2764
|
});
|
|
@@ -1991,6 +2766,12 @@ registerTool("get_ticket_state", {
|
|
|
1991
2766
|
// Jira Transitions
|
|
1992
2767
|
// ---------------------------------------------------------------------------
|
|
1993
2768
|
registerTool("get_jira_transitions", {
|
|
2769
|
+
annotations: {
|
|
2770
|
+
readOnlyHint: true,
|
|
2771
|
+
destructiveHint: false,
|
|
2772
|
+
idempotentHint: true,
|
|
2773
|
+
openWorldHint: true,
|
|
2774
|
+
},
|
|
1994
2775
|
description: "List available Jira workflow transitions for a ticket. Returns each transition's id, name, and target status. " +
|
|
1995
2776
|
"Use this to discover what status changes are possible for a given ticket. " +
|
|
1996
2777
|
"The repo_name is automatically injected from the configured environment.",
|
|
@@ -1999,11 +2780,17 @@ registerTool("get_jira_transitions", {
|
|
|
1999
2780
|
},
|
|
2000
2781
|
}, async ({ ticket_number }) => {
|
|
2001
2782
|
const url = buildGetUrl(`/tickets/${encodeURIComponent(ticket_number)}/jira-transitions`, { repo_name: REPO_NAME });
|
|
2002
|
-
const resp = await fetch(url, { headers:
|
|
2783
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
2003
2784
|
const text = await handleResponse(resp);
|
|
2004
2785
|
return { content: [{ type: "text", text }] };
|
|
2005
2786
|
});
|
|
2006
2787
|
registerTool("update_jira_status", {
|
|
2788
|
+
annotations: {
|
|
2789
|
+
readOnlyHint: false,
|
|
2790
|
+
destructiveHint: true,
|
|
2791
|
+
idempotentHint: true,
|
|
2792
|
+
openWorldHint: true,
|
|
2793
|
+
},
|
|
2007
2794
|
description: "Transition a Jira ticket to a specified target status by executing a workflow transition. " +
|
|
2008
2795
|
"Provide either target_status (matched case-insensitively against available transitions) or transition_id (used directly). " +
|
|
2009
2796
|
"If transition_id is provided, it takes precedence over target_status. " +
|
|
@@ -2024,16 +2811,24 @@ registerTool("update_jira_status", {
|
|
|
2024
2811
|
payload.transition_id = transition_id;
|
|
2025
2812
|
const resp = await fetch(buildUrl(`/tickets/${encodeURIComponent(ticket_number)}/jira-status`), {
|
|
2026
2813
|
method: "PUT",
|
|
2027
|
-
headers:
|
|
2814
|
+
headers: await getPostHeaders(),
|
|
2028
2815
|
body: JSON.stringify(payload),
|
|
2029
2816
|
});
|
|
2030
2817
|
const text = await handleResponse(resp);
|
|
2031
2818
|
return { content: [{ type: "text", text }] };
|
|
2032
2819
|
});
|
|
2033
2820
|
registerTool("resolve_target_status", {
|
|
2034
|
-
|
|
2035
|
-
|
|
2821
|
+
annotations: {
|
|
2822
|
+
readOnlyHint: false,
|
|
2823
|
+
destructiveHint: false,
|
|
2824
|
+
idempotentHint: true,
|
|
2825
|
+
openWorldHint: true,
|
|
2826
|
+
},
|
|
2827
|
+
description: "Ask an LLM agent to CHOOSE the project's post-PR target Jira status, and cache that choice per project. " +
|
|
2828
|
+
"The agent selects the single workflow status that best represents 'code committed via PR but not yet tested.' " +
|
|
2036
2829
|
"Results are cached per-project — subsequent calls return the cached value unless force_rerun is true. " +
|
|
2830
|
+
"This does NOT list all available transitions — use `get_jira_transitions` for the full transition list. " +
|
|
2831
|
+
"This also does NOT move the ticket — use `update_jira_status` to actually perform the status transition. " +
|
|
2037
2832
|
"Requires a ticket_number to fetch available transitions from Jira. " +
|
|
2038
2833
|
"The repo_name is automatically injected from the configured environment.",
|
|
2039
2834
|
inputSchema: {
|
|
@@ -2049,7 +2844,7 @@ registerTool("resolve_target_status", {
|
|
|
2049
2844
|
payload.force_rerun = force_rerun;
|
|
2050
2845
|
const resp = await fetch(buildUrl("/resolve-target-status"), {
|
|
2051
2846
|
method: "POST",
|
|
2052
|
-
headers:
|
|
2847
|
+
headers: await getPostHeaders(),
|
|
2053
2848
|
body: JSON.stringify(payload),
|
|
2054
2849
|
});
|
|
2055
2850
|
const text = await handleResponse(resp);
|
|
@@ -2060,6 +2855,7 @@ registerTool("resolve_target_status", {
|
|
|
2060
2855
|
// ---------------------------------------------------------------------------
|
|
2061
2856
|
const VALID_CONFIG_FIELDS = [
|
|
2062
2857
|
"review_instructions", "documentation_instructions", "architecture_instructions",
|
|
2858
|
+
"tdd_document_instructions", "fsd_document_instructions",
|
|
2063
2859
|
"unit_testing_instructions", "e2e_testing_instructions",
|
|
2064
2860
|
"unit_testing_stack", "e2e_testing_stack",
|
|
2065
2861
|
"frontend_correctness_standards", "backend_correctness_standards",
|
|
@@ -2069,8 +2865,24 @@ const VALID_CONFIG_FIELDS = [
|
|
|
2069
2865
|
"allow_mutating_smoke_ops",
|
|
2070
2866
|
"selected_mcp_slugs",
|
|
2071
2867
|
"base_branch",
|
|
2868
|
+
"difficulty_model_routing_enabled",
|
|
2869
|
+
"difficulty_model_tier_overrides",
|
|
2870
|
+
// BAPI-356 easy-install bootstrap fields (also exposed via the registry).
|
|
2871
|
+
"working_in",
|
|
2872
|
+
"version_control_system",
|
|
2873
|
+
"version",
|
|
2874
|
+
"project_description",
|
|
2875
|
+
"custom_directories",
|
|
2876
|
+
"exclude_directories",
|
|
2877
|
+
"exclude_file_extensions",
|
|
2072
2878
|
].join(", ");
|
|
2073
2879
|
registerTool("list_config_fields", {
|
|
2880
|
+
annotations: {
|
|
2881
|
+
readOnlyHint: true,
|
|
2882
|
+
destructiveHint: false,
|
|
2883
|
+
idempotentHint: true,
|
|
2884
|
+
openWorldHint: true,
|
|
2885
|
+
},
|
|
2074
2886
|
description: "List all configurable fields available for reading and updating via the Bridge API. " +
|
|
2075
2887
|
"Returns each field's name and a description of its purpose. No database values are returned — " +
|
|
2076
2888
|
"use get_config_field to read a specific field's current value. " +
|
|
@@ -2078,11 +2890,17 @@ registerTool("list_config_fields", {
|
|
|
2078
2890
|
inputSchema: {},
|
|
2079
2891
|
}, async () => {
|
|
2080
2892
|
const url = buildGetUrl("/config-fields", { repo_name: REPO_NAME });
|
|
2081
|
-
const resp = await fetch(url, { headers:
|
|
2893
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
2082
2894
|
const text = await handleResponse(resp);
|
|
2083
2895
|
return { content: [{ type: "text", text }] };
|
|
2084
2896
|
});
|
|
2085
2897
|
registerTool("get_my_role", {
|
|
2898
|
+
annotations: {
|
|
2899
|
+
readOnlyHint: true,
|
|
2900
|
+
destructiveHint: false,
|
|
2901
|
+
idempotentHint: true,
|
|
2902
|
+
openWorldHint: true,
|
|
2903
|
+
},
|
|
2086
2904
|
description: "Check the role and auth source for the current API key. " +
|
|
2087
2905
|
"Returns JSON with {role: \"admin\" | \"member\" | null, source: \"user_access\" | \"legacy\"}. " +
|
|
2088
2906
|
"Use this to check if the current key has admin permissions before attempting configuration updates " +
|
|
@@ -2090,35 +2908,61 @@ registerTool("get_my_role", {
|
|
|
2090
2908
|
inputSchema: {},
|
|
2091
2909
|
}, async () => {
|
|
2092
2910
|
const url = buildGetUrl("/my-role", { repo_name: REPO_NAME });
|
|
2093
|
-
const resp = await fetch(url, { headers:
|
|
2911
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
2094
2912
|
const text = await handleResponse(resp);
|
|
2095
2913
|
return { content: [{ type: "text", text }] };
|
|
2096
2914
|
});
|
|
2097
2915
|
registerTool("get_config_field", {
|
|
2916
|
+
annotations: {
|
|
2917
|
+
readOnlyHint: true,
|
|
2918
|
+
destructiveHint: false,
|
|
2919
|
+
idempotentHint: true,
|
|
2920
|
+
openWorldHint: true,
|
|
2921
|
+
},
|
|
2098
2922
|
description: "Read the current value and metadata for a specific configuration field. " +
|
|
2099
2923
|
"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
|
-
"Use this before update_config_field to understand the current state and build upon it rather than overwriting blindly."
|
|
2924
|
+
"Use this before update_config_field to understand the current state and build upon it rather than overwriting blindly. " +
|
|
2925
|
+
"For install bootstrap, prefer get_install_manifest (one read of all bootstrap fields) over many individual get_config_field reads.",
|
|
2101
2926
|
inputSchema: {
|
|
2102
2927
|
field_name: z.string().describe(`The configuration field to read. Valid options: ${VALID_CONFIG_FIELDS}`),
|
|
2103
2928
|
},
|
|
2104
2929
|
}, async ({ field_name }) => {
|
|
2105
2930
|
const url = buildGetUrl(`/config-field/${encodeURIComponent(field_name)}`, { repo_name: REPO_NAME });
|
|
2106
|
-
const resp = await fetch(url, { headers:
|
|
2931
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
2107
2932
|
const text = await handleResponse(resp);
|
|
2108
2933
|
return { content: [{ type: "text", text }] };
|
|
2109
2934
|
});
|
|
2110
2935
|
registerTool("update_config_field", {
|
|
2936
|
+
annotations: {
|
|
2937
|
+
readOnlyHint: false,
|
|
2938
|
+
destructiveHint: true,
|
|
2939
|
+
idempotentHint: true,
|
|
2940
|
+
openWorldHint: true,
|
|
2941
|
+
},
|
|
2111
2942
|
description: "Update a specific configuration field in the Bridge database. " +
|
|
2112
2943
|
"These fields control LLM behavior during code review, planning, and documentation. " +
|
|
2113
2944
|
"Always call get_config_field first to read the current value and build upon it. " +
|
|
2945
|
+
"For install bootstrap, prefer apply_install_manifest (one atomic write of all bootstrap fields, " +
|
|
2946
|
+
"with server-owned skip/conflict/confirmation semantics) over many individual update_config_field writes. " +
|
|
2114
2947
|
"Returns 400 if the field name is invalid, 404 if the repository has no configuration row yet.",
|
|
2115
2948
|
inputSchema: {
|
|
2116
2949
|
field_name: z.string().describe(`The configuration field to update. Valid options: ${VALID_CONFIG_FIELDS}`),
|
|
2117
|
-
value: z.union([
|
|
2118
|
-
|
|
2950
|
+
value: z.union([
|
|
2951
|
+
z.string(),
|
|
2952
|
+
z.boolean(),
|
|
2953
|
+
z.array(z.string()),
|
|
2954
|
+
z.record(z.string(), z.union([z.string(), z.null()])),
|
|
2955
|
+
]).optional().describe("The new value for the configuration field. Provide either value or file_path, not both. " +
|
|
2956
|
+
"Most fields take a string; scalar boolean fields (e.g. allow_mutating_smoke_ops, " +
|
|
2957
|
+
"difficulty_model_routing_enabled) take true/false. " +
|
|
2119
2958
|
"The selected_mcp_slugs field takes a JSON array of supported MCP validation manual slug strings " +
|
|
2120
2959
|
"(e.g. [\"b2c-commerce-developer\", \"playwright-mcp\", \"pwa-kit-mcp\"]) — pass an array of strings, " +
|
|
2121
2960
|
"not a comma-delimited string; an empty array clears the selection. " +
|
|
2961
|
+
"The difficulty_model_tier_overrides field takes a JSON object mapping tier names " +
|
|
2962
|
+
"(\"cheap\"/\"basic\"/\"premium\") to per-repo model aliases (e.g. {\"premium\": \"opus\"}) — pass " +
|
|
2963
|
+
"an object, not a string; an empty object clears all overrides. " +
|
|
2964
|
+
"The difficulty_model_routing_enabled field enables difficulty-based /start-tickets model routing " +
|
|
2965
|
+
"(default ON); pass true/false. " +
|
|
2122
2966
|
"The base_branch field is a string/null field controlling the development base branch used by PR " +
|
|
2123
2967
|
"creation (/create-pr) and start-tickets worktree creation; an empty/null value clears it and " +
|
|
2124
2968
|
"automations fall back to 'main'. " +
|
|
@@ -2129,8 +2973,17 @@ registerTool("update_config_field", {
|
|
|
2129
2973
|
"Useful for large configuration values like detailed review instructions. " +
|
|
2130
2974
|
"The file must be UTF-8 encoded and under 1MB. " +
|
|
2131
2975
|
"Not supported for scalar boolean fields like allow_mutating_smoke_ops."),
|
|
2976
|
+
only_if_null: z.boolean().optional().describe("Secondary conditional-write guard: when true, the field is updated only if its column is " +
|
|
2977
|
+
"currently NULL (returns status 'skipped'/reason 'already_set' otherwise). Legal only for " +
|
|
2978
|
+
"nullable columns (HTTP 422 otherwise). This is NOT the install bootstrap write path — for " +
|
|
2979
|
+
"easy install, prefer apply_install_manifest, which owns skip/conflict/confirmation semantics."),
|
|
2132
2980
|
},
|
|
2133
|
-
}, async ({ field_name, value, file_path }) => {
|
|
2981
|
+
}, async ({ field_name, value, file_path, only_if_null }) => {
|
|
2982
|
+
// Build the PUT body, including only_if_null only when explicitly true so
|
|
2983
|
+
// the default (false) request shape is unchanged for existing callers.
|
|
2984
|
+
const withGuard = (v) => only_if_null === true
|
|
2985
|
+
? { repo_name: REPO_NAME, value: v, only_if_null: true }
|
|
2986
|
+
: { repo_name: REPO_NAME, value: v };
|
|
2134
2987
|
// JSONB array config fields (e.g. selected_mcp_slugs): forward the array value
|
|
2135
2988
|
// as JSON. Never join into a comma-delimited string — the backend expects a
|
|
2136
2989
|
// real JSON array and validates each slug against the mcp_docs allowlist.
|
|
@@ -2152,15 +3005,40 @@ registerTool("update_config_field", {
|
|
|
2152
3005
|
const arrayValue = value === undefined ? [] : value;
|
|
2153
3006
|
const resp = await fetch(buildUrl(`/config-field/${encodeURIComponent(field_name)}`), {
|
|
2154
3007
|
method: "PUT",
|
|
2155
|
-
headers:
|
|
2156
|
-
body: JSON.stringify(
|
|
3008
|
+
headers: await getPostHeaders(),
|
|
3009
|
+
body: JSON.stringify(withGuard(arrayValue)),
|
|
3010
|
+
});
|
|
3011
|
+
const text = await handleResponse(resp);
|
|
3012
|
+
return { content: [{ type: "text", text }] };
|
|
3013
|
+
}
|
|
3014
|
+
// JSONB object config fields (e.g. difficulty_model_tier_overrides): forward the
|
|
3015
|
+
// object value as JSON. Reject file_path; the backend validates/normalizes keys
|
|
3016
|
+
// and alias values. An omitted value clears all overrides (empty object).
|
|
3017
|
+
const JSON_OBJECT_CONFIG_FIELDS = ["difficulty_model_tier_overrides"];
|
|
3018
|
+
if (JSON_OBJECT_CONFIG_FIELDS.includes(field_name)) {
|
|
3019
|
+
if (file_path) {
|
|
3020
|
+
return {
|
|
3021
|
+
isError: true,
|
|
3022
|
+
content: [{
|
|
3023
|
+
type: "text",
|
|
3024
|
+
text: JSON.stringify({
|
|
3025
|
+
error: `'${field_name}' is a JSON object field; file_path updates are not supported. Pass value as an object mapping tier names to model aliases.`,
|
|
3026
|
+
}),
|
|
3027
|
+
}],
|
|
3028
|
+
};
|
|
3029
|
+
}
|
|
3030
|
+
const objectValue = value === undefined ? {} : value;
|
|
3031
|
+
const resp = await fetch(buildUrl(`/config-field/${encodeURIComponent(field_name)}`), {
|
|
3032
|
+
method: "PUT",
|
|
3033
|
+
headers: await getPostHeaders(),
|
|
3034
|
+
body: JSON.stringify(withGuard(objectValue)),
|
|
2157
3035
|
});
|
|
2158
3036
|
const text = await handleResponse(resp);
|
|
2159
3037
|
return { content: [{ type: "text", text }] };
|
|
2160
3038
|
}
|
|
2161
3039
|
// Scalar boolean config fields: reject file-path updates and normalize boolean
|
|
2162
3040
|
// true/false and string "true"/"false" to a real boolean before persisting.
|
|
2163
|
-
const BOOLEAN_CONFIG_FIELDS = ["allow_mutating_smoke_ops"];
|
|
3041
|
+
const BOOLEAN_CONFIG_FIELDS = ["allow_mutating_smoke_ops", "difficulty_model_routing_enabled"];
|
|
2164
3042
|
if (BOOLEAN_CONFIG_FIELDS.includes(field_name)) {
|
|
2165
3043
|
if (file_path) {
|
|
2166
3044
|
return {
|
|
@@ -2201,8 +3079,8 @@ registerTool("update_config_field", {
|
|
|
2201
3079
|
}
|
|
2202
3080
|
const resp = await fetch(buildUrl(`/config-field/${encodeURIComponent(field_name)}`), {
|
|
2203
3081
|
method: "PUT",
|
|
2204
|
-
headers:
|
|
2205
|
-
body: JSON.stringify(
|
|
3082
|
+
headers: await getPostHeaders(),
|
|
3083
|
+
body: JSON.stringify(withGuard(boolValue)),
|
|
2206
3084
|
});
|
|
2207
3085
|
const text = await handleResponse(resp);
|
|
2208
3086
|
return { content: [{ type: "text", text }] };
|
|
@@ -2219,12 +3097,72 @@ registerTool("update_config_field", {
|
|
|
2219
3097
|
}
|
|
2220
3098
|
const resp = await fetch(buildUrl(`/config-field/${encodeURIComponent(field_name)}`), {
|
|
2221
3099
|
method: "PUT",
|
|
2222
|
-
headers:
|
|
2223
|
-
body: JSON.stringify(
|
|
3100
|
+
headers: await getPostHeaders(),
|
|
3101
|
+
body: JSON.stringify(withGuard(finalValue)),
|
|
2224
3102
|
});
|
|
2225
3103
|
const text = await handleResponse(resp);
|
|
2226
3104
|
return { content: [{ type: "text", text: text + note }] };
|
|
2227
3105
|
});
|
|
3106
|
+
// ---------------------------------------------------------------------------
|
|
3107
|
+
// Easy Install (BAPI-356): read-the-manifest / apply-the-manifest
|
|
3108
|
+
// ---------------------------------------------------------------------------
|
|
3109
|
+
registerTool("get_install_manifest", {
|
|
3110
|
+
annotations: {
|
|
3111
|
+
readOnlyHint: true,
|
|
3112
|
+
destructiveHint: false,
|
|
3113
|
+
idempotentHint: true,
|
|
3114
|
+
openWorldHint: true,
|
|
3115
|
+
},
|
|
3116
|
+
description: "Read the easy-install configuration manifest for the configured repository in one call. " +
|
|
3117
|
+
"Returns ordered field groups (each bootstrap field with its current value, is_set flag, agent " +
|
|
3118
|
+
"guidance, examples, and validation summary), a list of deferred fields (owned by " +
|
|
3119
|
+
"/learn-repository or set deliberately), a next_step pointer, done_criteria, and a signed " +
|
|
3120
|
+
"snapshot_token. Pass that exact snapshot_token to apply_install_manifest. " +
|
|
3121
|
+
"Prefer this over many individual get_config_field reads during install bootstrap.",
|
|
3122
|
+
inputSchema: {
|
|
3123
|
+
save_locally: z.boolean().optional().default(true).describe("When true (default), also save the full JSON manifest under BAPI_DOCS_DIR/install/."),
|
|
3124
|
+
},
|
|
3125
|
+
}, async ({ save_locally }) => {
|
|
3126
|
+
const url = buildGetUrl("/config/install-manifest", { repo_name: REPO_NAME });
|
|
3127
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
3128
|
+
const ok = resp.ok;
|
|
3129
|
+
let text = await handleResponse(resp);
|
|
3130
|
+
if (ok && save_locally !== false) {
|
|
3131
|
+
const safeRepo = safeTicketFileSegment(REPO_NAME || "repo");
|
|
3132
|
+
const filename = `${safeRepo}-install-manifest-${safeTimestampForFilename()}.json`;
|
|
3133
|
+
const note = await saveLocally(await getDocsPath("install"), filename, text);
|
|
3134
|
+
text = text + note;
|
|
3135
|
+
}
|
|
3136
|
+
return { content: [{ type: "text", text }] };
|
|
3137
|
+
});
|
|
3138
|
+
registerTool("apply_install_manifest", {
|
|
3139
|
+
annotations: {
|
|
3140
|
+
readOnlyHint: false,
|
|
3141
|
+
destructiveHint: true,
|
|
3142
|
+
idempotentHint: false,
|
|
3143
|
+
openWorldHint: true,
|
|
3144
|
+
},
|
|
3145
|
+
description: "Apply easy-install configuration in one atomic call. Pass the snapshot_token returned by " +
|
|
3146
|
+
"get_install_manifest plus a fields object. Each field value is either a scalar " +
|
|
3147
|
+
"(e.g. \"base_branch\": \"main\") or an object (e.g. \"project_description\": {\"value\": \"...\", " +
|
|
3148
|
+
"\"confirmed\": true}). project_description MUST be passed as {value, confirmed: true} and only " +
|
|
3149
|
+
"after explicit human approval. The server owns skip-if-set, conflict detection, and " +
|
|
3150
|
+
"confirmation semantics and returns six buckets: applied, skipped, conflict, rejected, " +
|
|
3151
|
+
"deferred, needs_confirmation. Only bootstrap-eligible fields are accepted (others are rejected).",
|
|
3152
|
+
inputSchema: {
|
|
3153
|
+
snapshot_token: z.string().describe("The exact snapshot_token returned by get_install_manifest for this repository."),
|
|
3154
|
+
fields: z.record(z.string(), z.any()).describe("Map of field_name to value. A value is either a scalar or an object {value, confirmed}. " +
|
|
3155
|
+
"Pass project_description only as {value: \"...\", confirmed: true} after human approval."),
|
|
3156
|
+
},
|
|
3157
|
+
}, async ({ snapshot_token, fields }) => {
|
|
3158
|
+
const resp = await fetch(buildUrl("/config/apply-install-manifest"), {
|
|
3159
|
+
method: "POST",
|
|
3160
|
+
headers: await getPostHeaders(),
|
|
3161
|
+
body: JSON.stringify({ repo_name: REPO_NAME, snapshot_token, fields }),
|
|
3162
|
+
});
|
|
3163
|
+
const text = await handleResponse(resp);
|
|
3164
|
+
return { content: [{ type: "text", text }] };
|
|
3165
|
+
});
|
|
2228
3166
|
function formatDeepResearchProviderReason(meta) {
|
|
2229
3167
|
if (!meta)
|
|
2230
3168
|
return "";
|
|
@@ -2282,8 +3220,14 @@ function formatDeepResearchStatus(body, taskId) {
|
|
|
2282
3220
|
return `Status: ${body.status}${elapsed}${reason} (task_id: ${taskId}). Try again in a minute.`;
|
|
2283
3221
|
}
|
|
2284
3222
|
registerTool("request_deep_research", {
|
|
2285
|
-
|
|
2286
|
-
|
|
3223
|
+
annotations: {
|
|
3224
|
+
readOnlyHint: false,
|
|
3225
|
+
destructiveHint: false,
|
|
3226
|
+
idempotentHint: false,
|
|
3227
|
+
openWorldHint: true,
|
|
3228
|
+
},
|
|
3229
|
+
description: "START async deep research on a technical topic using AI-powered web search. " +
|
|
3230
|
+
"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
3231
|
"\n\n" +
|
|
2288
3232
|
"WHEN TO USE: Use this tool when you need to investigate unfamiliar technologies, " +
|
|
2289
3233
|
"gather implementation guidance across multiple sources, research best practices for a complex topic, " +
|
|
@@ -2325,7 +3269,7 @@ registerTool("request_deep_research", {
|
|
|
2325
3269
|
submitPayload.ticket_number = ticket_number;
|
|
2326
3270
|
const submitResp = await fetch(buildUrl("/deep-research"), {
|
|
2327
3271
|
method: "POST",
|
|
2328
|
-
headers:
|
|
3272
|
+
headers: await getPostHeaders(),
|
|
2329
3273
|
body: JSON.stringify(submitPayload),
|
|
2330
3274
|
});
|
|
2331
3275
|
if (!submitResp.ok) {
|
|
@@ -2351,7 +3295,7 @@ registerTool("request_deep_research", {
|
|
|
2351
3295
|
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
2352
3296
|
console.error(`Deep research in progress... (elapsed: ${elapsed}s, status: ${lastStatus})`);
|
|
2353
3297
|
const statusUrl = buildGetUrl(`/deep-research/${taskId}/status`, { repo_name: REPO_NAME });
|
|
2354
|
-
const statusResp = await fetch(statusUrl, { headers:
|
|
3298
|
+
const statusResp = await fetch(statusUrl, { headers: await getGetHeaders() });
|
|
2355
3299
|
if (!statusResp.ok) {
|
|
2356
3300
|
const errorText = await handleResponse(statusResp);
|
|
2357
3301
|
return { content: [{ type: "text", text: JSON.stringify({ error: "INTERNAL_ERROR", status: 500, message: `Error polling deep research status: ${errorText}` }) }] };
|
|
@@ -2388,7 +3332,7 @@ registerTool("request_deep_research", {
|
|
|
2388
3332
|
}
|
|
2389
3333
|
// 3. Retrieve the result
|
|
2390
3334
|
const resultUrl = buildGetUrl(`/deep-research/${taskId}/result`, { repo_name: REPO_NAME });
|
|
2391
|
-
const resultResp = await fetch(resultUrl, { headers:
|
|
3335
|
+
const resultResp = await fetch(resultUrl, { headers: await getGetHeaders() });
|
|
2392
3336
|
if (!resultResp.ok) {
|
|
2393
3337
|
const errorText = await handleResponse(resultResp);
|
|
2394
3338
|
return { content: [{ type: "text", text: JSON.stringify({ error: "INTERNAL_ERROR", status: 500, message: `Error retrieving deep research result: ${errorText}` }) }] };
|
|
@@ -2397,15 +3341,22 @@ registerTool("request_deep_research", {
|
|
|
2397
3341
|
// 4. Optionally save to file
|
|
2398
3342
|
if (save_locally) {
|
|
2399
3343
|
const slug = slugify(query);
|
|
2400
|
-
const note = await saveLocally(getDocsPath("deep-research"), `${slug}-${taskId}.md`, resultText);
|
|
3344
|
+
const note = await saveLocally(await getDocsPath("deep-research"), `${slug}-${taskId}.md`, resultText);
|
|
2401
3345
|
resultText += note;
|
|
2402
3346
|
}
|
|
2403
3347
|
return { content: [{ type: "text", text: resultText }] };
|
|
2404
3348
|
});
|
|
2405
3349
|
registerTool("get_deep_research", {
|
|
2406
|
-
|
|
3350
|
+
annotations: {
|
|
3351
|
+
readOnlyHint: true,
|
|
3352
|
+
destructiveHint: false,
|
|
3353
|
+
idempotentHint: true,
|
|
3354
|
+
openWorldHint: true,
|
|
3355
|
+
},
|
|
3356
|
+
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. " +
|
|
3357
|
+
"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
3358
|
"Returns the full markdown research report if the task is completed, " +
|
|
2408
|
-
"or a structured status response
|
|
3359
|
+
"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
3360
|
"Use this after calling request_deep_research with wait_for_result=false.",
|
|
2410
3361
|
inputSchema: {
|
|
2411
3362
|
task_id: z.number().describe("The task ID returned by request_deep_research."),
|
|
@@ -2416,7 +3367,7 @@ registerTool("get_deep_research", {
|
|
|
2416
3367
|
}, async ({ task_id, query_slug, save_locally }) => {
|
|
2417
3368
|
// 1. Check status
|
|
2418
3369
|
const statusUrl = buildGetUrl(`/deep-research/${task_id}/status`, { repo_name: REPO_NAME });
|
|
2419
|
-
const statusResp = await fetch(statusUrl, { headers:
|
|
3370
|
+
const statusResp = await fetch(statusUrl, { headers: await getGetHeaders() });
|
|
2420
3371
|
if (!statusResp.ok) {
|
|
2421
3372
|
const errorText = await handleResponse(statusResp);
|
|
2422
3373
|
return { content: [{ type: "text", text: errorText }] };
|
|
@@ -2440,7 +3391,7 @@ registerTool("get_deep_research", {
|
|
|
2440
3391
|
}
|
|
2441
3392
|
// 2. Retrieve the result
|
|
2442
3393
|
const resultUrl = buildGetUrl(`/deep-research/${task_id}/result`, { repo_name: REPO_NAME });
|
|
2443
|
-
const resultResp = await fetch(resultUrl, { headers:
|
|
3394
|
+
const resultResp = await fetch(resultUrl, { headers: await getGetHeaders() });
|
|
2444
3395
|
if (!resultResp.ok) {
|
|
2445
3396
|
const errorText = await handleResponse(resultResp);
|
|
2446
3397
|
return { content: [{ type: "text", text: errorText }] };
|
|
@@ -2449,11 +3400,15 @@ registerTool("get_deep_research", {
|
|
|
2449
3400
|
// 3. Optionally save to file
|
|
2450
3401
|
if (save_locally) {
|
|
2451
3402
|
const slug = query_slug || "research";
|
|
2452
|
-
const note = await saveLocally(getDocsPath("deep-research"), `${slug}-${task_id}.md`, resultText);
|
|
3403
|
+
const note = await saveLocally(await getDocsPath("deep-research"), `${slug}-${task_id}.md`, resultText);
|
|
2453
3404
|
resultText += note;
|
|
2454
3405
|
}
|
|
2455
3406
|
return { content: [{ type: "text", text: resultText }] };
|
|
2456
3407
|
});
|
|
3408
|
+
// ``BrainstormResultRow`` / ``BrainstormResultEnvelope`` types,
|
|
3409
|
+
// ``sanitizeProviderForFilename``, and the brainstorm filename/save helpers now
|
|
3410
|
+
// live in ``./brainstorm-files.ts`` so they can be unit-tested directly (see the
|
|
3411
|
+
// import near the top of this file).
|
|
2457
3412
|
const BRAINSTORM_TERMINAL_STATUSES = new Set([
|
|
2458
3413
|
"completed",
|
|
2459
3414
|
"failed",
|
|
@@ -2462,16 +3417,6 @@ const BRAINSTORM_TERMINAL_STATUSES = new Set([
|
|
|
2462
3417
|
function isBrainstormTerminalStatus(status) {
|
|
2463
3418
|
return BRAINSTORM_TERMINAL_STATUSES.has(status);
|
|
2464
3419
|
}
|
|
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
3420
|
async function pollBrainstormUntilTerminal(brainstormId, repoName) {
|
|
2476
3421
|
const startTime = Date.now();
|
|
2477
3422
|
const MAX_TIMEOUT_MS = 15 * 60 * 1000;
|
|
@@ -2480,7 +3425,7 @@ async function pollBrainstormUntilTerminal(brainstormId, repoName) {
|
|
|
2480
3425
|
while (Date.now() - startTime < MAX_TIMEOUT_MS) {
|
|
2481
3426
|
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
2482
3427
|
const statusUrl = buildGetUrl(`/brainstorms/${brainstormId}/status`, { repo_name: repoName });
|
|
2483
|
-
const statusResp = await fetch(statusUrl, { headers:
|
|
3428
|
+
const statusResp = await fetch(statusUrl, { headers: await getGetHeaders() });
|
|
2484
3429
|
if (!statusResp.ok) {
|
|
2485
3430
|
return latest;
|
|
2486
3431
|
}
|
|
@@ -2495,27 +3440,13 @@ async function pollBrainstormUntilTerminal(brainstormId, repoName) {
|
|
|
2495
3440
|
}
|
|
2496
3441
|
return latest;
|
|
2497
3442
|
}
|
|
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;
|
|
3443
|
+
async function saveBrainstormResultsLocally(envelope, subject) {
|
|
3444
|
+
// Resolve the docs directory here, then delegate the write loop and filename
|
|
3445
|
+
// construction to the pure, unit-tested helper in ``./brainstorm-files.ts``.
|
|
3446
|
+
// When ``subject`` (the original ``task_description``) is provided, files get
|
|
3447
|
+
// semantic names; otherwise they fall back to the UUID-only pattern.
|
|
3448
|
+
const dir = await getDocsPath("brainstorm");
|
|
3449
|
+
return saveBrainstormResultsToDir(envelope, dir, subject);
|
|
2519
3450
|
}
|
|
2520
3451
|
function formatBrainstormToolResponse(envelope, savedPaths) {
|
|
2521
3452
|
const lines = [];
|
|
@@ -2544,14 +3475,28 @@ function formatBrainstormToolResponse(envelope, savedPaths) {
|
|
|
2544
3475
|
return lines.join("\n");
|
|
2545
3476
|
}
|
|
2546
3477
|
registerTool("request_brainstorm", {
|
|
2547
|
-
|
|
3478
|
+
annotations: {
|
|
3479
|
+
readOnlyHint: false,
|
|
3480
|
+
destructiveHint: false,
|
|
3481
|
+
idempotentHint: false,
|
|
3482
|
+
openWorldHint: true,
|
|
3483
|
+
},
|
|
3484
|
+
description: "START an async brainstorm that fans out the task to two opinion-provider LLMs " +
|
|
2548
3485
|
"(default: OpenAI + Gemini) and then runs a synthesizer pass over the completed opinions. " +
|
|
2549
|
-
"Returns a brainstorm_id
|
|
3486
|
+
"Returns a brainstorm_id; the matching get_brainstorm tool retrieves the finished result later (unless you set wait_for_result). " +
|
|
2550
3487
|
"\n\n" +
|
|
2551
3488
|
"BEHAVIOR: By default, returns immediately with a brainstorm_id. Set wait_for_result=true " +
|
|
2552
3489
|
"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
|
-
"
|
|
3490
|
+
"When save_locally=true (default), each provider's markdown is written with a semantic " +
|
|
3491
|
+
"filename derived from task_description: " +
|
|
3492
|
+
"BAPI_DOCS_DIR/brainstorm/{slugified-task-description}-{short_brainstorm_id}-{provider}.md, " +
|
|
3493
|
+
"and the synthesizer row follows the same pattern " +
|
|
3494
|
+
"({slugified-task-description}-{short_brainstorm_id}-synthesizer.md). " +
|
|
3495
|
+
"(If task_description is empty or slugifies to nothing, it falls back to {brainstorm_id}-{provider}.md.) " +
|
|
3496
|
+
"\n\n" +
|
|
3497
|
+
"DESIGN MODE: Set design=true to run the brainstorm as web-page/UI design ideation focused on " +
|
|
3498
|
+
"visual appeal and conversion, rather than the default software-engineering framing. Omit the " +
|
|
3499
|
+
"design field unless you want design mode (absent is treated as false).",
|
|
2555
3500
|
inputSchema: {
|
|
2556
3501
|
task_description: z.string().describe("Free-form description of the task to brainstorm about. Sent verbatim — " +
|
|
2557
3502
|
"this tool does NOT read task_description from a file."),
|
|
@@ -2564,12 +3509,14 @@ registerTool("request_brainstorm", {
|
|
|
2564
3509
|
wait_for_result: z.boolean().optional().describe("When true, polls until every row reaches a terminal status (max 15 minutes), " +
|
|
2565
3510
|
"then returns the full result envelope. When false (default), returns immediately."),
|
|
2566
3511
|
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."
|
|
3512
|
+
"after the result is fetched. Request-time saves use semantic filenames derived from " +
|
|
3513
|
+
"task_description (slugified) plus a short brainstorm-id segment."),
|
|
2568
3514
|
prior_brainstorm_id: z.string().optional().describe("Optional brainstorm_id from an earlier brainstorm to refine. " +
|
|
2569
3515
|
"When provided, the new brainstorm receives the prior brainstorm's " +
|
|
2570
3516
|
"synthesizer markdown, or a completed opinion-provider fallback, as prior context."),
|
|
3517
|
+
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
3518
|
},
|
|
2572
|
-
}, async ({ task_description, repo_name, ticket_number, providers, concerns, wait_for_result, save_locally, prior_brainstorm_id, }) => {
|
|
3519
|
+
}, async ({ task_description, repo_name, ticket_number, providers, concerns, wait_for_result, save_locally, prior_brainstorm_id, design, }) => {
|
|
2573
3520
|
const effectiveRepo = repo_name && repo_name.length > 0 ? repo_name : REPO_NAME;
|
|
2574
3521
|
const effectiveProviders = providers !== undefined ? providers : ["openai", "gemini"];
|
|
2575
3522
|
const shouldWait = wait_for_result === true;
|
|
@@ -2586,9 +3533,11 @@ registerTool("request_brainstorm", {
|
|
|
2586
3533
|
if (prior_brainstorm_id) {
|
|
2587
3534
|
submitPayload.prior_brainstorm_request_id = prior_brainstorm_id;
|
|
2588
3535
|
}
|
|
3536
|
+
if (design)
|
|
3537
|
+
submitPayload.design = true;
|
|
2589
3538
|
const submitResp = await fetch(buildUrl("/brainstorms"), {
|
|
2590
3539
|
method: "POST",
|
|
2591
|
-
headers:
|
|
3540
|
+
headers: await getPostHeaders(),
|
|
2592
3541
|
body: JSON.stringify(submitPayload),
|
|
2593
3542
|
});
|
|
2594
3543
|
if (!submitResp.ok) {
|
|
@@ -2614,7 +3563,7 @@ registerTool("request_brainstorm", {
|
|
|
2614
3563
|
};
|
|
2615
3564
|
}
|
|
2616
3565
|
const resultUrl = buildGetUrl(`/brainstorms/${submitBody.brainstorm_id}/result`, { repo_name: effectiveRepo });
|
|
2617
|
-
const resultResp = await fetch(resultUrl, { headers:
|
|
3566
|
+
const resultResp = await fetch(resultUrl, { headers: await getGetHeaders() });
|
|
2618
3567
|
if (!resultResp.ok) {
|
|
2619
3568
|
const errorText = await handleResponse(resultResp);
|
|
2620
3569
|
return { content: [{ type: "text", text: errorText }] };
|
|
@@ -2622,7 +3571,9 @@ registerTool("request_brainstorm", {
|
|
|
2622
3571
|
const envelope = (await resultResp.json());
|
|
2623
3572
|
let savedPaths = [];
|
|
2624
3573
|
if (shouldSave) {
|
|
2625
|
-
|
|
3574
|
+
// Request-time saves use semantic filenames derived from the in-scope
|
|
3575
|
+
// ``task_description``.
|
|
3576
|
+
savedPaths = await saveBrainstormResultsLocally(envelope, task_description);
|
|
2626
3577
|
}
|
|
2627
3578
|
return {
|
|
2628
3579
|
content: [{
|
|
@@ -2632,21 +3583,33 @@ registerTool("request_brainstorm", {
|
|
|
2632
3583
|
};
|
|
2633
3584
|
});
|
|
2634
3585
|
registerTool("get_brainstorm", {
|
|
2635
|
-
|
|
2636
|
-
|
|
3586
|
+
annotations: {
|
|
3587
|
+
readOnlyHint: true,
|
|
3588
|
+
destructiveHint: false,
|
|
3589
|
+
idempotentHint: true,
|
|
3590
|
+
openWorldHint: true,
|
|
3591
|
+
},
|
|
3592
|
+
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. " +
|
|
3593
|
+
"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. " +
|
|
3594
|
+
"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
3595
|
"When save_locally=true (default), writes each provider's markdown to " +
|
|
2638
3596
|
"BAPI_DOCS_DIR/brainstorm/{brainstorm_id}-{provider}.md (including the synthesizer file). " +
|
|
3597
|
+
"Retrieval uses this UUID-only filename (not the semantic task-description name that " +
|
|
3598
|
+
"request_brainstorm uses) because the original task description is not available in the " +
|
|
3599
|
+
"result envelope on retrieval. " +
|
|
2639
3600
|
"DB-only retrieval — never falls back to Jira attachments.",
|
|
2640
3601
|
inputSchema: {
|
|
2641
3602
|
brainstorm_id: z.string().describe("The brainstorm_id (UUID) returned by request_brainstorm."),
|
|
2642
3603
|
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/."
|
|
3604
|
+
save_locally: z.boolean().optional().describe("When true (default), writes each provider's markdown to BAPI_DOCS_DIR/brainstorm/. " +
|
|
3605
|
+
"Retrieval-time saves intentionally use the UUID-only fallback filename " +
|
|
3606
|
+
"({brainstorm_id}-{provider}.md), not a task-description-derived semantic name."),
|
|
2644
3607
|
},
|
|
2645
3608
|
}, async ({ brainstorm_id, repo_name, save_locally }) => {
|
|
2646
3609
|
const effectiveRepo = repo_name && repo_name.length > 0 ? repo_name : REPO_NAME;
|
|
2647
3610
|
const shouldSave = save_locally !== false;
|
|
2648
3611
|
const resultUrl = buildGetUrl(`/brainstorms/${brainstorm_id}/result`, { repo_name: effectiveRepo });
|
|
2649
|
-
const resultResp = await fetch(resultUrl, { headers:
|
|
3612
|
+
const resultResp = await fetch(resultUrl, { headers: await getGetHeaders() });
|
|
2650
3613
|
if (!resultResp.ok) {
|
|
2651
3614
|
const errorText = await handleResponse(resultResp);
|
|
2652
3615
|
return { content: [{ type: "text", text: errorText }] };
|
|
@@ -2654,6 +3617,9 @@ registerTool("get_brainstorm", {
|
|
|
2654
3617
|
const envelope = (await resultResp.json());
|
|
2655
3618
|
let savedPaths = [];
|
|
2656
3619
|
if (shouldSave) {
|
|
3620
|
+
// ``get_brainstorm`` intentionally uses the UUID-only fallback: the result
|
|
3621
|
+
// envelope does not echo the original ``task_description``, so no semantic
|
|
3622
|
+
// subject is available here. (Documented asymmetry with ``request_brainstorm``.)
|
|
2657
3623
|
savedPaths = await saveBrainstormResultsLocally(envelope);
|
|
2658
3624
|
}
|
|
2659
3625
|
return {
|
|
@@ -2667,6 +3633,12 @@ registerTool("get_brainstorm", {
|
|
|
2667
3633
|
// VCS & CI Tools
|
|
2668
3634
|
// ---------------------------------------------------------------------------
|
|
2669
3635
|
registerTool("create_pull_request", {
|
|
3636
|
+
annotations: {
|
|
3637
|
+
readOnlyHint: false,
|
|
3638
|
+
destructiveHint: false,
|
|
3639
|
+
idempotentHint: true,
|
|
3640
|
+
openWorldHint: true,
|
|
3641
|
+
},
|
|
2670
3642
|
description: "Create a pull request on the configured VCS provider (GitHub or Bitbucket). " +
|
|
2671
3643
|
"Returns a structured response with {available, reason, action, detail}. " +
|
|
2672
3644
|
"If a PR already exists for the head branch, returns it with created=false. " +
|
|
@@ -2689,13 +3661,19 @@ registerTool("create_pull_request", {
|
|
|
2689
3661
|
payload.body = body;
|
|
2690
3662
|
const resp = await fetch(buildUrl("/vcs/pull-requests"), {
|
|
2691
3663
|
method: "POST",
|
|
2692
|
-
headers:
|
|
3664
|
+
headers: await getPostHeaders(),
|
|
2693
3665
|
body: JSON.stringify(payload),
|
|
2694
3666
|
});
|
|
2695
3667
|
const text = await handleResponse(resp);
|
|
2696
3668
|
return { content: [{ type: "text", text }] };
|
|
2697
3669
|
});
|
|
2698
3670
|
const resolveCiChecksTool = registerTool("resolve_ci_checks", {
|
|
3671
|
+
annotations: {
|
|
3672
|
+
readOnlyHint: false,
|
|
3673
|
+
destructiveHint: false,
|
|
3674
|
+
idempotentHint: true,
|
|
3675
|
+
openWorldHint: true,
|
|
3676
|
+
},
|
|
2699
3677
|
description: "Discover and classify CI checks for the configured repository. " +
|
|
2700
3678
|
"Queries GitHub Check Runs + Commit Statuses APIs (or Bitbucket Build Statuses), " +
|
|
2701
3679
|
"then uses Branch Protection API or LLM to determine which checks are required for merging. " +
|
|
@@ -2715,7 +3693,7 @@ const resolveCiChecksTool = registerTool("resolve_ci_checks", {
|
|
|
2715
3693
|
payload.force_rerun = force_rerun;
|
|
2716
3694
|
const resp = await fetch(buildUrl("/resolve-ci-checks"), {
|
|
2717
3695
|
method: "POST",
|
|
2718
|
-
headers:
|
|
3696
|
+
headers: await getPostHeaders(),
|
|
2719
3697
|
body: JSON.stringify(payload),
|
|
2720
3698
|
});
|
|
2721
3699
|
const text = await handleResponse(resp);
|
|
@@ -2732,6 +3710,12 @@ const resolveCiChecksTool = registerTool("resolve_ci_checks", {
|
|
|
2732
3710
|
return { content: [{ type: "text", text }] };
|
|
2733
3711
|
});
|
|
2734
3712
|
const pollCiChecksTool = registerTool("poll_ci_checks", {
|
|
3713
|
+
annotations: {
|
|
3714
|
+
readOnlyHint: true,
|
|
3715
|
+
destructiveHint: false,
|
|
3716
|
+
idempotentHint: true,
|
|
3717
|
+
openWorldHint: true,
|
|
3718
|
+
},
|
|
2735
3719
|
description: "Poll the current status of CI checks for a specific commit. " +
|
|
2736
3720
|
"Requires that resolve_ci_checks has been called first to populate the check configuration. " +
|
|
2737
3721
|
"Returns per-check status, all_complete, all_passed, and unknown_checks fields. " +
|
|
@@ -2746,7 +3730,7 @@ const pollCiChecksTool = registerTool("poll_ci_checks", {
|
|
|
2746
3730
|
repo_name: REPO_NAME,
|
|
2747
3731
|
commit_ref,
|
|
2748
3732
|
});
|
|
2749
|
-
const resp = await fetch(url, { headers:
|
|
3733
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
2750
3734
|
const text = await handleResponse(resp);
|
|
2751
3735
|
return { content: [{ type: "text", text }] };
|
|
2752
3736
|
});
|
|
@@ -2756,7 +3740,7 @@ const pollCiChecksTool = registerTool("poll_ci_checks", {
|
|
|
2756
3740
|
async function checkCiConfigAndDisablePoll() {
|
|
2757
3741
|
try {
|
|
2758
3742
|
const url = buildGetUrl("/config-field/ci_check_config", { repo_name: REPO_NAME });
|
|
2759
|
-
const resp = await fetch(url, { headers:
|
|
3743
|
+
const resp = await fetch(url, { headers: await getGetHeaders() });
|
|
2760
3744
|
if (resp.ok) {
|
|
2761
3745
|
const body = await resp.json();
|
|
2762
3746
|
const value = body.value;
|
|
@@ -2780,42 +3764,45 @@ async function checkCiConfigAndDisablePoll() {
|
|
|
2780
3764
|
// Check config before connecting
|
|
2781
3765
|
await checkCiConfigAndDisablePoll();
|
|
2782
3766
|
// ---------------------------------------------------------------------------
|
|
2783
|
-
// Custom pipeline loading (BAPI-275)
|
|
3767
|
+
// Custom pipeline loading (BAPI-275, deferred in BAPI-338)
|
|
2784
3768
|
// ---------------------------------------------------------------------------
|
|
2785
3769
|
//
|
|
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
|
-
}
|
|
3770
|
+
// Custom user pipelines are merged into PIPELINES + INSTRUCTIONS by
|
|
3771
|
+
// `ensureCustomPipelinesLoaded()` (defined above). It is NO LONGER run at module
|
|
3772
|
+
// load — the pipelines dir resolution depends on the project root, which may
|
|
3773
|
+
// come from MCP `roots/list` and is only available after the server connects.
|
|
3774
|
+
// Instead, every catalog-dependent handler (`list_pipelines`,
|
|
3775
|
+
// `get_pipeline_recipe`) and the orchestrator-deps builders await it first, so
|
|
3776
|
+
// they all observe the same final merged objects.
|
|
2803
3777
|
// ---------------------------------------------------------------------------
|
|
2804
3778
|
// Pipeline Recipe Tools
|
|
2805
3779
|
// ---------------------------------------------------------------------------
|
|
2806
3780
|
registerTool("get_docs_dir", {
|
|
3781
|
+
annotations: {
|
|
3782
|
+
readOnlyHint: true,
|
|
3783
|
+
destructiveHint: false,
|
|
3784
|
+
idempotentHint: true,
|
|
3785
|
+
openWorldHint: false,
|
|
3786
|
+
},
|
|
2807
3787
|
description: "Return the locally configured docs directory path (BAPI_DOCS_DIR, default docs/tmp). " +
|
|
2808
3788
|
"No parameters. Use this instead of reading the BAPI_DOCS_DIR environment variable directly, " +
|
|
2809
3789
|
"which requires shell access and may be blocked on some AI coding platforms.",
|
|
2810
3790
|
inputSchema: {},
|
|
2811
3791
|
}, async () => {
|
|
2812
|
-
return { content: [{ type: "text", text:
|
|
3792
|
+
return { content: [{ type: "text", text: await getDocsDir() }] };
|
|
2813
3793
|
});
|
|
2814
3794
|
registerTool("list_pipelines", {
|
|
3795
|
+
annotations: {
|
|
3796
|
+
readOnlyHint: true,
|
|
3797
|
+
destructiveHint: false,
|
|
3798
|
+
idempotentHint: true,
|
|
3799
|
+
openWorldHint: false,
|
|
3800
|
+
},
|
|
2815
3801
|
description: "List all available pipeline recipes with their names, descriptions, and required variables. " +
|
|
2816
3802
|
"No parameters. Use this to discover available pipelines before calling get_pipeline_recipe.",
|
|
2817
3803
|
inputSchema: {},
|
|
2818
3804
|
}, async () => {
|
|
3805
|
+
await ensureCustomPipelinesLoaded();
|
|
2819
3806
|
const list = Object.entries(PIPELINES).map(([key, pipeline]) => ({
|
|
2820
3807
|
name: key,
|
|
2821
3808
|
description: pipeline.description ?? "",
|
|
@@ -2827,6 +3814,12 @@ registerTool("list_pipelines", {
|
|
|
2827
3814
|
};
|
|
2828
3815
|
});
|
|
2829
3816
|
registerTool("get_pipeline_recipe", {
|
|
3817
|
+
annotations: {
|
|
3818
|
+
readOnlyHint: true,
|
|
3819
|
+
destructiveHint: false,
|
|
3820
|
+
idempotentHint: true,
|
|
3821
|
+
openWorldHint: false,
|
|
3822
|
+
},
|
|
2830
3823
|
description: "Retrieve a fully resolved pipeline recipe by name. Substitutes variables, resolves instruction " +
|
|
2831
3824
|
"file references to inline content, and returns an ordered array of executable steps. " +
|
|
2832
3825
|
"Each step is either an mcp_call (with tool name and params) or an agent_task (with instruction text). " +
|
|
@@ -2854,6 +3847,7 @@ registerTool("get_pipeline_recipe", {
|
|
|
2854
3847
|
"not via the variables map."),
|
|
2855
3848
|
},
|
|
2856
3849
|
}, async ({ pipeline: pipelineName, variables, skip_steps, auto_approve }) => {
|
|
3850
|
+
await ensureCustomPipelinesLoaded();
|
|
2857
3851
|
const pipelineDef = PIPELINES[pipelineName];
|
|
2858
3852
|
if (!pipelineDef) {
|
|
2859
3853
|
const available = Object.keys(PIPELINES).join(", ");
|
|
@@ -2882,7 +3876,7 @@ registerTool("get_pipeline_recipe", {
|
|
|
2882
3876
|
}
|
|
2883
3877
|
try {
|
|
2884
3878
|
const mergedVariables = {
|
|
2885
|
-
docs_dir:
|
|
3879
|
+
docs_dir: await getDocsDir(),
|
|
2886
3880
|
provider: "",
|
|
2887
3881
|
second_opinion: "",
|
|
2888
3882
|
auto_approve: auto_approve ? "true" : "",
|
|
@@ -2925,21 +3919,13 @@ registerTool("get_pipeline_recipe", {
|
|
|
2925
3919
|
// REPO_MISMATCH | TOOL_ERROR). Pipeline state is persisted server-side; the
|
|
2926
3920
|
// idle TTL defaults to 24 hours and is auto-extended on every state
|
|
2927
3921
|
// 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() {
|
|
3922
|
+
async function buildPipelineOrchestratorDeps() {
|
|
3923
|
+
await ensureCustomPipelinesLoaded();
|
|
2938
3924
|
return {
|
|
2939
3925
|
baseUrl: BASE_URL,
|
|
2940
|
-
apiKey:
|
|
3926
|
+
apiKey: await getResolvedApiKey(),
|
|
2941
3927
|
repoName: REPO_NAME,
|
|
2942
|
-
docsDir:
|
|
3928
|
+
docsDir: await getDocsDir(),
|
|
2943
3929
|
pipelines: PIPELINES,
|
|
2944
3930
|
instructions: INSTRUCTIONS,
|
|
2945
3931
|
toolHandlers: TOOL_HANDLERS,
|
|
@@ -2948,12 +3934,13 @@ function buildPipelineOrchestratorDeps() {
|
|
|
2948
3934
|
// BAPI-326: dependency injection for the full-automation chain orchestrator.
|
|
2949
3935
|
// Same shape as the pipeline deps plus the bundled chain-recipe registry. The
|
|
2950
3936
|
// chain orchestrator never imports index.ts; everything flows through here.
|
|
2951
|
-
function buildChainOrchestratorDeps() {
|
|
3937
|
+
async function buildChainOrchestratorDeps() {
|
|
3938
|
+
await ensureCustomPipelinesLoaded();
|
|
2952
3939
|
return {
|
|
2953
3940
|
baseUrl: BASE_URL,
|
|
2954
|
-
apiKey:
|
|
3941
|
+
apiKey: await getResolvedApiKey(),
|
|
2955
3942
|
repoName: REPO_NAME,
|
|
2956
|
-
docsDir:
|
|
3943
|
+
docsDir: await getDocsDir(),
|
|
2957
3944
|
pipelines: PIPELINES,
|
|
2958
3945
|
chainRecipes: CHAIN_RECIPES,
|
|
2959
3946
|
instructions: INSTRUCTIONS,
|
|
@@ -2961,6 +3948,12 @@ function buildChainOrchestratorDeps() {
|
|
|
2961
3948
|
};
|
|
2962
3949
|
}
|
|
2963
3950
|
registerTool("run_pipeline", {
|
|
3951
|
+
annotations: {
|
|
3952
|
+
readOnlyHint: false,
|
|
3953
|
+
destructiveHint: false,
|
|
3954
|
+
idempotentHint: false,
|
|
3955
|
+
openWorldHint: true,
|
|
3956
|
+
},
|
|
2964
3957
|
description: "Execute a Bridge API pipeline by name. The orchestrator runs steps sequentially, " +
|
|
2965
3958
|
"dispatching mcp_call steps in-process and pausing on agent_task steps with a " +
|
|
2966
3959
|
"needs_agent_task envelope. Returns a unified envelope keyed on `status`: " +
|
|
@@ -2970,8 +3963,8 @@ registerTool("run_pipeline", {
|
|
|
2970
3963
|
"VALIDATION | NOT_FOUND | EXPIRED | REPO_MISMATCH | TOOL_ERROR). " +
|
|
2971
3964
|
"Paused runs auto-expire after an idle TTL (default 24 hours; override with " +
|
|
2972
3965
|
"`ttl_seconds`). The TTL is reset on every state transition.\n\n" +
|
|
2973
|
-
"
|
|
2974
|
-
|
|
3966
|
+
"Call list_pipelines for the current resolved catalog of available pipeline " +
|
|
3967
|
+
"names (bundled plus any custom user pipelines).",
|
|
2975
3968
|
inputSchema: {
|
|
2976
3969
|
pipeline: z
|
|
2977
3970
|
.string()
|
|
@@ -2996,7 +3989,7 @@ registerTool("run_pipeline", {
|
|
|
2996
3989
|
.describe("Override the default 24-hour idle TTL for this run. Must be a positive integer."),
|
|
2997
3990
|
},
|
|
2998
3991
|
}, async (input) => {
|
|
2999
|
-
const result = await runPipeline(buildPipelineOrchestratorDeps(), input);
|
|
3992
|
+
const result = await runPipeline(await buildPipelineOrchestratorDeps(), input);
|
|
3000
3993
|
return {
|
|
3001
3994
|
content: [
|
|
3002
3995
|
{ type: "text", text: JSON.stringify(result, null, 2) },
|
|
@@ -3004,6 +3997,12 @@ registerTool("run_pipeline", {
|
|
|
3004
3997
|
};
|
|
3005
3998
|
});
|
|
3006
3999
|
registerTool("resume_pipeline", {
|
|
4000
|
+
annotations: {
|
|
4001
|
+
readOnlyHint: false,
|
|
4002
|
+
destructiveHint: false,
|
|
4003
|
+
idempotentHint: false,
|
|
4004
|
+
openWorldHint: true,
|
|
4005
|
+
},
|
|
3007
4006
|
description: "Resume a paused pipeline run with the result of the agent_task. Provide the " +
|
|
3008
4007
|
"`pipeline_run_id` returned by the prior needs_agent_task envelope, and the string " +
|
|
3009
4008
|
"the instruction's `## Return` section asked you to produce as `agent_result`. " +
|
|
@@ -3019,7 +4018,7 @@ registerTool("resume_pipeline", {
|
|
|
3019
4018
|
.describe("The string the paused instruction's ## Return section asked you to produce"),
|
|
3020
4019
|
},
|
|
3021
4020
|
}, async (input) => {
|
|
3022
|
-
const result = await resumePipeline(buildPipelineOrchestratorDeps(), input);
|
|
4021
|
+
const result = await resumePipeline(await buildPipelineOrchestratorDeps(), input);
|
|
3023
4022
|
return {
|
|
3024
4023
|
content: [
|
|
3025
4024
|
{ type: "text", text: JSON.stringify(result, null, 2) },
|
|
@@ -3027,6 +4026,12 @@ registerTool("resume_pipeline", {
|
|
|
3027
4026
|
};
|
|
3028
4027
|
});
|
|
3029
4028
|
registerTool("list_pipeline_runs", {
|
|
4029
|
+
annotations: {
|
|
4030
|
+
readOnlyHint: true,
|
|
4031
|
+
destructiveHint: false,
|
|
4032
|
+
idempotentHint: true,
|
|
4033
|
+
openWorldHint: true,
|
|
4034
|
+
},
|
|
3030
4035
|
description: "List recent pipeline runs for the configured repository, newest first. Returns " +
|
|
3031
4036
|
"metadata only — `resolved_recipe`, resolved params, instruction text, results, " +
|
|
3032
4037
|
"and agent outputs are intentionally excluded. Use this to recover a " +
|
|
@@ -3040,7 +4045,7 @@ registerTool("list_pipeline_runs", {
|
|
|
3040
4045
|
.describe("Optional status filter"),
|
|
3041
4046
|
},
|
|
3042
4047
|
}, async (input) => {
|
|
3043
|
-
const result = await listPipelineRuns(buildPipelineOrchestratorDeps(), input);
|
|
4048
|
+
const result = await listPipelineRuns(await buildPipelineOrchestratorDeps(), input);
|
|
3044
4049
|
return {
|
|
3045
4050
|
content: [
|
|
3046
4051
|
{ type: "text", text: JSON.stringify(result, null, 2) },
|
|
@@ -3048,6 +4053,12 @@ registerTool("list_pipeline_runs", {
|
|
|
3048
4053
|
};
|
|
3049
4054
|
});
|
|
3050
4055
|
registerTool("delete_pipeline_run", {
|
|
4056
|
+
annotations: {
|
|
4057
|
+
readOnlyHint: false,
|
|
4058
|
+
destructiveHint: true,
|
|
4059
|
+
idempotentHint: true,
|
|
4060
|
+
openWorldHint: true,
|
|
4061
|
+
},
|
|
3051
4062
|
description: "Delete a pipeline run row (any status). Use this to discard orphaned `running` " +
|
|
3052
4063
|
"rows from a previous session that can't be resumed (resume_pipeline only accepts " +
|
|
3053
4064
|
"`paused`), to clean up after a failed run, or to remove a no-longer-needed paused " +
|
|
@@ -3061,7 +4072,7 @@ registerTool("delete_pipeline_run", {
|
|
|
3061
4072
|
.describe("UUID of the pipeline run to delete."),
|
|
3062
4073
|
},
|
|
3063
4074
|
}, async (input) => {
|
|
3064
|
-
const result = await deletePipelineRun(buildPipelineOrchestratorDeps(), input);
|
|
4075
|
+
const result = await deletePipelineRun(await buildPipelineOrchestratorDeps(), input);
|
|
3065
4076
|
return {
|
|
3066
4077
|
content: [
|
|
3067
4078
|
{ type: "text", text: JSON.stringify(result, null, 2) },
|
|
@@ -3081,6 +4092,12 @@ registerTool("delete_pipeline_run", {
|
|
|
3081
4092
|
// TOOL_HANDLERS and the pipeline tools so the orchestrator can dispatch the
|
|
3082
4093
|
// existing child pipelines in-process.
|
|
3083
4094
|
registerTool("run_full_automation", {
|
|
4095
|
+
annotations: {
|
|
4096
|
+
readOnlyHint: false,
|
|
4097
|
+
destructiveHint: false,
|
|
4098
|
+
idempotentHint: false,
|
|
4099
|
+
openWorldHint: true,
|
|
4100
|
+
},
|
|
3084
4101
|
description: "Run the full-automation chain for an idea: create ticket(s) " +
|
|
3085
4102
|
"(idea-to-ticket), review each created ticket (review-ticket fan-out), " +
|
|
3086
4103
|
"then emit the exact `/start-tickets ...` command for you to invoke in " +
|
|
@@ -3120,7 +4137,7 @@ registerTool("run_full_automation", {
|
|
|
3120
4137
|
if (!resolved.ok) {
|
|
3121
4138
|
return resolved.errorResponse;
|
|
3122
4139
|
}
|
|
3123
|
-
const result = await runFullAutomation(buildChainOrchestratorDeps(), {
|
|
4140
|
+
const result = await runFullAutomation(await buildChainOrchestratorDeps(), {
|
|
3124
4141
|
idea: resolved.text,
|
|
3125
4142
|
...rest,
|
|
3126
4143
|
});
|
|
@@ -3131,6 +4148,12 @@ registerTool("run_full_automation", {
|
|
|
3131
4148
|
};
|
|
3132
4149
|
});
|
|
3133
4150
|
registerTool("resume_full_automation", {
|
|
4151
|
+
annotations: {
|
|
4152
|
+
readOnlyHint: false,
|
|
4153
|
+
destructiveHint: false,
|
|
4154
|
+
idempotentHint: false,
|
|
4155
|
+
openWorldHint: true,
|
|
4156
|
+
},
|
|
3134
4157
|
description: "Resume a paused full-automation chain run. Provide the `chain_run_id` " +
|
|
3135
4158
|
"returned by the prior needs_agent_task envelope and the string the " +
|
|
3136
4159
|
"instruction asked you to produce as `agent_result`. Returns the same " +
|
|
@@ -3140,7 +4163,7 @@ registerTool("resume_full_automation", {
|
|
|
3140
4163
|
agent_result: z.string(),
|
|
3141
4164
|
},
|
|
3142
4165
|
}, async (input) => {
|
|
3143
|
-
const result = await resumeFullAutomation(buildChainOrchestratorDeps(), input);
|
|
4166
|
+
const result = await resumeFullAutomation(await buildChainOrchestratorDeps(), input);
|
|
3144
4167
|
return {
|
|
3145
4168
|
content: [
|
|
3146
4169
|
{ type: "text", text: JSON.stringify(result, null, 2) },
|
|
@@ -3150,12 +4173,125 @@ registerTool("resume_full_automation", {
|
|
|
3150
4173
|
// ---------------------------------------------------------------------------
|
|
3151
4174
|
// generate_decision_page
|
|
3152
4175
|
// ---------------------------------------------------------------------------
|
|
4176
|
+
/**
|
|
4177
|
+
* Detect URL-encoded path tokens that could smuggle traversal/separators past
|
|
4178
|
+
* literal-character checks: `%2e` (dot), `%2f` (slash), and `%5c` (backslash),
|
|
4179
|
+
* all case-insensitive. Defense-in-depth alongside the literal-character rules.
|
|
4180
|
+
*/
|
|
4181
|
+
export function containsUnsafeEncodedPathToken(value) {
|
|
4182
|
+
return /%2e/i.test(value) || /%2f/i.test(value) || /%5c/i.test(value);
|
|
4183
|
+
}
|
|
4184
|
+
/**
|
|
4185
|
+
* True when `value` is absolute on POSIX OR Windows semantics, regardless of the
|
|
4186
|
+
* host OS. Node's `path.isAbsolute` is platform-specific, so a Windows drive
|
|
4187
|
+
* (`C:\`) or UNC (`\\server\share`) path would not be caught on a POSIX host
|
|
4188
|
+
* without checking `path.win32.isAbsolute` explicitly.
|
|
4189
|
+
*/
|
|
4190
|
+
export function isPlatformAbsolutePath(value) {
|
|
4191
|
+
return (path.posix.isAbsolute(value) ||
|
|
4192
|
+
path.win32.isAbsolute(value) ||
|
|
4193
|
+
path.isAbsolute(value));
|
|
4194
|
+
}
|
|
4195
|
+
/**
|
|
4196
|
+
* Validate the docs-relative `output_subdir`. Returns a user-facing error
|
|
4197
|
+
* message string when invalid, or `null` when valid. Never sanitizes/auto-fixes.
|
|
4198
|
+
*/
|
|
4199
|
+
export function validateDecisionPageOutputSubdir(value) {
|
|
4200
|
+
if (value.trim().length === 0) {
|
|
4201
|
+
return `Invalid output_subdir: must not be empty or whitespace-only.`;
|
|
4202
|
+
}
|
|
4203
|
+
if (value.includes("\0")) {
|
|
4204
|
+
return `Invalid output_subdir: must not contain null bytes.`;
|
|
4205
|
+
}
|
|
4206
|
+
if (containsUnsafeEncodedPathToken(value)) {
|
|
4207
|
+
return `Invalid output_subdir "${value}": must not contain encoded path tokens (%2e, %2f, %5c).`;
|
|
4208
|
+
}
|
|
4209
|
+
if (isPlatformAbsolutePath(value)) {
|
|
4210
|
+
return `Invalid output_subdir "${value}": must be a relative path, not an absolute path.`;
|
|
4211
|
+
}
|
|
4212
|
+
if (value.includes("\\")) {
|
|
4213
|
+
return `Invalid output_subdir "${value}": backslashes are not allowed; use "/" to separate nested directories.`;
|
|
4214
|
+
}
|
|
4215
|
+
// Split on both separators so a "\\"-bearing value (already rejected above) and
|
|
4216
|
+
// "/"-separated nesting are both checked segment-by-segment for "..".
|
|
4217
|
+
const segments = value.split(/[/\\]/);
|
|
4218
|
+
if (segments.some((segment) => segment === "..")) {
|
|
4219
|
+
return `Invalid output_subdir "${value}": must not contain ".." path segments.`;
|
|
4220
|
+
}
|
|
4221
|
+
return null;
|
|
4222
|
+
}
|
|
4223
|
+
/**
|
|
4224
|
+
* Validate the `output_filename`. Returns a user-facing error message string
|
|
4225
|
+
* when invalid, or `null` when valid. Requires a literal `.html` suffix and
|
|
4226
|
+
* never auto-appends it.
|
|
4227
|
+
*/
|
|
4228
|
+
export function validateDecisionPageOutputFilename(value) {
|
|
4229
|
+
if (value.trim().length === 0) {
|
|
4230
|
+
return `Invalid output_filename: must not be empty or whitespace-only.`;
|
|
4231
|
+
}
|
|
4232
|
+
if (value.includes("\0")) {
|
|
4233
|
+
return `Invalid output_filename: must not contain null bytes.`;
|
|
4234
|
+
}
|
|
4235
|
+
if (containsUnsafeEncodedPathToken(value)) {
|
|
4236
|
+
return `Invalid output_filename "${value}": must not contain encoded path tokens (%2e, %2f, %5c).`;
|
|
4237
|
+
}
|
|
4238
|
+
if (value.includes("/") || value.includes("\\")) {
|
|
4239
|
+
return `Invalid output_filename "${value}": must not contain path separators.`;
|
|
4240
|
+
}
|
|
4241
|
+
if (value === "." || value === "..") {
|
|
4242
|
+
return `Invalid output_filename "${value}": must be a real filename, not "." or "..".`;
|
|
4243
|
+
}
|
|
4244
|
+
if (!value.endsWith(".html")) {
|
|
4245
|
+
return `Invalid output_filename "${value}": must end with the ".html" suffix.`;
|
|
4246
|
+
}
|
|
4247
|
+
return null;
|
|
4248
|
+
}
|
|
4249
|
+
/**
|
|
4250
|
+
* Validate and resolve the decision-page output target under the docs base.
|
|
4251
|
+
* Runs string validators first (so an invalid subdir short-circuits before any
|
|
4252
|
+
* docs-dir resolution), then resolves an absolute target and enforces a
|
|
4253
|
+
* containment backstop that the target stays strictly under the docs base.
|
|
4254
|
+
*/
|
|
4255
|
+
export async function resolveDecisionPageOutputTarget(outputSubdir, outputFilename) {
|
|
4256
|
+
const subdirError = validateDecisionPageOutputSubdir(outputSubdir);
|
|
4257
|
+
if (subdirError)
|
|
4258
|
+
return { ok: false, message: subdirError };
|
|
4259
|
+
const filenameError = validateDecisionPageOutputFilename(outputFilename);
|
|
4260
|
+
if (filenameError)
|
|
4261
|
+
return { ok: false, message: filenameError };
|
|
4262
|
+
// getDocsDir() is async — must be awaited before path.resolve, otherwise the
|
|
4263
|
+
// Promise would be string-coerced into a bogus path.
|
|
4264
|
+
const docsBase = path.resolve(await getDocsDir());
|
|
4265
|
+
const resolvedTarget = path.resolve(docsBase, outputSubdir, outputFilename);
|
|
4266
|
+
// Containment backstop using normalized resolved paths. The strict
|
|
4267
|
+
// `docsBase + path.sep` boundary prevents a sibling-prefix bypass such as
|
|
4268
|
+
// "/tmp/docs-evil" passing a naive startsWith("/tmp/docs") check.
|
|
4269
|
+
if (!resolvedTarget.startsWith(docsBase + path.sep)) {
|
|
4270
|
+
return {
|
|
4271
|
+
ok: false,
|
|
4272
|
+
message: `Invalid output target: the resolved output path must stay under the docs directory.`,
|
|
4273
|
+
};
|
|
4274
|
+
}
|
|
4275
|
+
return {
|
|
4276
|
+
ok: true,
|
|
4277
|
+
docsPath: path.dirname(resolvedTarget),
|
|
4278
|
+
filePath: resolvedTarget,
|
|
4279
|
+
};
|
|
4280
|
+
}
|
|
3153
4281
|
registerTool("generate_decision_page", {
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
4282
|
+
annotations: {
|
|
4283
|
+
readOnlyHint: false,
|
|
4284
|
+
destructiveHint: false,
|
|
4285
|
+
idempotentHint: true,
|
|
4286
|
+
openWorldHint: true,
|
|
4287
|
+
},
|
|
4288
|
+
description: "Generate a local, reusable, review-shaped HTML decision page for capturing user decisions. " +
|
|
4289
|
+
"Renders recommendation-driven decision cards with per-option consequence lines, optional " +
|
|
4290
|
+
"original-question and closed-by-default codebase-evidence display, and an optional confirmed-" +
|
|
4291
|
+
"improvements list. Presentation labels (title, intro, section/improvements headings) are " +
|
|
4292
|
+
"overridable, and the output location under the docs directory is configurable, so automations " +
|
|
4293
|
+
"beyond ticket review can reuse it. The user opens the HTML file in a browser, makes selections, " +
|
|
4294
|
+
"and copies the resulting JSON output back to the agent.",
|
|
3159
4295
|
inputSchema: DecisionPageInputShape,
|
|
3160
4296
|
}, async (input) => {
|
|
3161
4297
|
// Returned messages travel through JSON.stringify in the MCP envelope below,
|
|
@@ -3205,9 +4341,19 @@ registerTool("generate_decision_page", {
|
|
|
3205
4341
|
}
|
|
3206
4342
|
seenCiIds.add(ci.id);
|
|
3207
4343
|
}
|
|
4344
|
+
// Resolve the (optionally caller-overridden) output location. Defaults
|
|
4345
|
+
// reproduce the legacy review path: {docs_dir}/review/{ticket_key}-decisions.html.
|
|
4346
|
+
// Runs after semantic item validation and before any asset read or file write.
|
|
4347
|
+
const outputSubdir = input.output_subdir ?? "review";
|
|
4348
|
+
const outputFilename = input.output_filename ?? `${input.ticket_key}-decisions.html`;
|
|
4349
|
+
const outputTarget = await resolveDecisionPageOutputTarget(outputSubdir, outputFilename);
|
|
4350
|
+
if (!outputTarget.ok) {
|
|
4351
|
+
return validationError(outputTarget.message);
|
|
4352
|
+
}
|
|
3208
4353
|
// Read design assets and base64-encode for embedding
|
|
3209
|
-
const
|
|
3210
|
-
const
|
|
4354
|
+
const projectRootForAssets = await getProjectRoot();
|
|
4355
|
+
const assetsDir = path.join(projectRootForAssets, "design-assets");
|
|
4356
|
+
const fontsDir = path.join(projectRootForAssets, "public", "fonts");
|
|
3211
4357
|
let faviconBase64 = "";
|
|
3212
4358
|
let logoBase64 = "";
|
|
3213
4359
|
try {
|
|
@@ -3220,15 +4366,16 @@ registerTool("generate_decision_page", {
|
|
|
3220
4366
|
logoBase64 = logoBuf.toString("base64");
|
|
3221
4367
|
}
|
|
3222
4368
|
catch { /* logo optional */ }
|
|
3223
|
-
//
|
|
3224
|
-
|
|
4369
|
+
// Use the validated output target. fontsRelPath stays relative to the actual
|
|
4370
|
+
// output directory so font URLs resolve regardless of a custom subdir.
|
|
4371
|
+
const docsPath = outputTarget.docsPath;
|
|
4372
|
+
const filePath = outputTarget.filePath;
|
|
3225
4373
|
const fontsRelPath = path.relative(docsPath, fontsDir);
|
|
3226
4374
|
const html = generateDecisionPageHtml(input, {
|
|
3227
4375
|
faviconBase64,
|
|
3228
4376
|
logoBase64,
|
|
3229
4377
|
fontsRelPath,
|
|
3230
4378
|
});
|
|
3231
|
-
const filePath = path.join(docsPath, `${input.ticket_key}-decisions.html`);
|
|
3232
4379
|
await mkdir(docsPath, { recursive: true });
|
|
3233
4380
|
await writeFile(filePath, html, "utf-8");
|
|
3234
4381
|
return {
|
|
@@ -3248,6 +4395,9 @@ registerTool("generate_decision_page", {
|
|
|
3248
4395
|
// ---------------------------------------------------------------------------
|
|
3249
4396
|
const transport = new StdioServerTransport();
|
|
3250
4397
|
await server.connect(transport);
|
|
4398
|
+
// Record that the server has connected so project-root resolution may now query
|
|
4399
|
+
// MCP `roots/list` (it falls through to CLAUDE_PROJECT_DIR / cwd before this).
|
|
4400
|
+
serverConnected = true;
|
|
3251
4401
|
console.error("Bridge API MCP server running on stdio");
|
|
3252
4402
|
// Fire-and-forget update check — delay to let MCP client attach listeners
|
|
3253
4403
|
(async () => {
|