@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.
Files changed (45) hide show
  1. package/README.md +334 -196
  2. package/build/agent-capabilities/cli.js +152 -0
  3. package/build/agent-capabilities/default-deps.js +45 -0
  4. package/build/agent-capabilities/probe-context.js +111 -0
  5. package/build/agent-capabilities/probes.js +278 -0
  6. package/build/agent-capabilities/reporter.js +50 -0
  7. package/build/agent-capabilities/runner.js +56 -0
  8. package/build/agent-capabilities/types.js +10 -0
  9. package/build/agent-launchers/claude.js +25 -17
  10. package/build/agent-launchers/cursor.js +65 -0
  11. package/build/agent-launchers/index.js +23 -8
  12. package/build/agent-registry.js +68 -0
  13. package/build/agents.generated.js +1 -1
  14. package/build/brainstorm-files.js +89 -0
  15. package/build/bridge-config.js +404 -0
  16. package/build/chain-orchestrator.js +247 -33
  17. package/build/command-catalog.js +376 -0
  18. package/build/commands.generated.js +10 -7
  19. package/build/credential-materialization.js +128 -0
  20. package/build/credential-store.js +232 -0
  21. package/build/decision-page-schema.js +39 -6
  22. package/build/decision-page-template.js +54 -18
  23. package/build/doctor.js +18 -2
  24. package/build/git-ignore-utils.js +63 -0
  25. package/build/index.js +1707 -557
  26. package/build/mcp-invoke.js +417 -0
  27. package/build/mcp-provisioning.js +342 -0
  28. package/build/mcp-registration-doctor.js +96 -0
  29. package/build/pipeline-orchestrator.js +9 -1
  30. package/build/pipelines.generated.js +5 -3
  31. package/build/schedule-run.js +440 -92
  32. package/build/schedule-store.js +41 -1
  33. package/build/scheduled-prompt.js +109 -0
  34. package/build/scheduler-backends/at-fallback.js +5 -10
  35. package/build/scheduler-backends/escaping.js +40 -10
  36. package/build/scheduler-backends/launchd.js +23 -14
  37. package/build/scheduler-backends/systemd-user.js +32 -19
  38. package/build/scheduler-backends/task-scheduler.js +8 -13
  39. package/build/start-tickets-prereqs.js +90 -1
  40. package/build/start-tickets.js +563 -42
  41. package/build/third-party-mcp-targets.js +75 -0
  42. package/build/version.generated.js +1 -1
  43. package/package.json +4 -3
  44. package/pipelines/full-automation.json +3 -1
  45. 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
- const API_KEY = process.env.BAPI_API_KEY ?? "";
43
- const PROJECT_ROOT = process.env.BAPI_PROJECT_ROOT ?? process.cwd();
44
- const BAPI_DOCS_DIR = path.resolve(PROJECT_ROOT, process.env.BAPI_DOCS_DIR ?? "docs/tmp");
45
- const BAPI_PIPELINES_DIR = path.resolve(PROJECT_ROOT, process.env.BAPI_PIPELINES_DIR ?? ".bridge/pipelines");
46
- const GET_HEADERS = { "X-API-Key": API_KEY };
47
- const POST_HEADERS = {
48
- "X-API-Key": API_KEY,
49
- "Content-Type": "application/json",
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(BAPI_DOCS_DIR, subdir);
197
+ async function getDocsPath(subdir) {
198
+ return path.join(await getDocsDir(), subdir);
69
199
  }
70
- function slugify(text, maxLength = 60) {
71
- return text
72
- .toLowerCase()
73
- .replace(/[^a-z0-9\s-]/g, "")
74
- .trim()
75
- .replace(/\s+/g, "-")
76
- .replace(/-+/g, "-")
77
- .slice(0, maxLength)
78
- .replace(/-$/, "");
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: POST_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: GET_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
- const gitignorePath = path.join(cwd, ".gitignore");
244
- const entry = filePath.startsWith("/") ? path.relative(cwd, filePath) : filePath;
245
- let content = "";
246
- try {
247
- content = await readFile(gitignorePath, "utf-8");
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("\nUpdate BAPI_API_KEY and BAPI_REPO_NAME in your config files " +
615
- "get these values from the Bridge API setup UI at https://bridgegpt-api.com");
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: GET_HEADERS });
815
- const text = await handleResponse(resp);
816
- return { content: [{ type: "text", text }] };
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
- description: "Consult a different LLM model family for a sanity check or technical pushback on a recommendation, plan, or analysis you have already produced. " +
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: POST_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: GET_HEADERS });
862
- const text = await handleResponse(resp);
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: GET_HEADERS });
917
- const text = await handleResponse(resp);
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: GET_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: GET_HEADERS });
950
- const text = await handleResponse(resp);
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
- description: "Retrieve the AI-generated implementation plan for a Jira ticket. " +
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 if no plan has been generated yet (the ticket may not have been processed by Bridge API). " +
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 ({ ticket_number, save_locally }) => {
1019
- const url = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/plan`, { repo_name: REPO_NAME });
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
- description: "Retrieve the AI-generated architecture plan for a Jira ticket. " +
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 if no architecture plan has been generated yet. " +
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 ({ ticket_number, save_locally }) => {
1047
- const url = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/architecture-plan`, { repo_name: REPO_NAME });
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
- description: "Retrieve AI-generated clarifying questions (for feature/task tickets) or debugging guidance (for bug tickets) for a Jira ticket. " +
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 if no questions have been generated yet. " +
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 ({ ticket_number, save_locally }) => {
1074
- const url = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/clarifying-questions`, { repo_name: REPO_NAME });
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: POST_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: POST_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: GET_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: POST_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: POST_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: POST_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: GET_HEADERS });
1317
- const text = await handleResponse(resp);
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: GET_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(BAPI_DOCS_DIR, "attachments", safeTicket, safeFileName);
2227
+ : path.join(await getDocsDir(), "attachments", safeTicket, safeFileName);
1378
2228
  const resolvedSave = path.resolve(savePath);
1379
- const resolvedRoot = path.resolve(PROJECT_ROOT);
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
- description: "Request AI-generated implementation plan for a Jira ticket. " +
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
- "After submitting, use get_plan with the same ticket_number to check if results are available. " +
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.string().optional(),
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 ({ ticket_number, wait_for_result, save_locally, second_opinion, provider }) => {
1440
- const body = { repo_name: REPO_NAME };
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
- description: "Request AI-generated architecture plan for a Jira ticket. " +
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
- "After submitting, use get_architecture with the same ticket_number to check if results are available. " +
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.string().optional(),
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 ({ ticket_number, wait_for_result, save_locally, second_opinion, provider }) => {
1509
- const body = { repo_name: REPO_NAME };
1510
- const trimmedSecondOpinion = second_opinion?.trim();
1511
- if (trimmedSecondOpinion) {
1512
- body.provider_override = trimmedSecondOpinion;
1513
- }
1514
- const trimmedProvider = provider?.trim();
1515
- if (trimmedProvider && !trimmedSecondOpinion) {
1516
- body.provider = trimmedProvider;
1517
- }
1518
- const resp = await fetch(buildUrl(`/ticket/${encodeURIComponent(ticket_number)}/generate-architecture`), {
1519
- method: "POST",
1520
- headers: POST_HEADERS,
1521
- body: JSON.stringify(body),
1522
- });
1523
- if (!resp.ok) {
1524
- const errorText = await handleResponse(resp);
1525
- return {
1526
- content: [{ type: "text", text: `Failed to request architecture generation: ${errorText}` }],
1527
- };
1528
- }
1529
- if (wait_for_result) {
1530
- const getUrl = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/architecture-plan`, { repo_name: REPO_NAME });
1531
- const result = await pollForResult(getUrl, 900_000, `Architecture generation for ${ticket_number}`);
1532
- if (!result.ok) {
1533
- return { content: [{ type: "text", text: result.text }] };
1534
- }
1535
- let text = result.text;
1536
- if (save_locally) {
1537
- const note = await saveLocally(getDocsPath("architecture"), `${ticket_number}-architecture-plan.md`, text);
1538
- text += note;
1539
- }
1540
- return { content: [{ type: "text", text }] };
1541
- }
1542
- const confirmationText = `Architecture generation requested for ${ticket_number}. ` +
1543
- `Processing typically takes 2-4 minutes. ` +
1544
- `Use get_architecture with ticket_number "${ticket_number}" to retrieve the architecture plan once processing completes.`;
1545
- return { content: [{ type: "text", text: confirmationText }] };
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
- description: "Request AI-generated clarifying questions or debugging guidance for a Jira ticket. " +
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
- "After submitting, use get_clarifying_questions with the same ticket_number to check if results are available. " +
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.string().optional(),
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 ({ ticket_number, wait_for_result, save_locally, second_opinion, provider }) => {
1579
- const body = { repo_name: REPO_NAME };
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
- description: "Retrieve AI-generated ticket quality critique for a Jira ticket. " +
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 if no critique has been generated yet. " +
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 ({ ticket_number, save_locally }) => {
1638
- const url = buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/ticket-critique`, { repo_name: REPO_NAME });
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
- description: "Request AI-generated ticket critique for a Jira ticket. " +
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
- "After submitting, use get_ticket_critique with the same ticket_number to check if results are available. " +
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.string().optional(),
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 ({ ticket_number, wait_for_result, save_locally, second_opinion, provider }) => {
1679
- const body = { repo_name: REPO_NAME };
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.string().optional(),
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 ({ ticket_number, wait_for_result, save_locally, second_opinion, provider }) => {
1751
- const body = { repo_name: REPO_NAME };
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
- description: "Request processing of new attachments and context assembly for a previously-implemented Jira ticket. " +
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
- "After submitting, use get_reimplement_context with the same ticket_number to retrieve the assembled context. " +
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.string().optional(),
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 ({ ticket_number, wait_for_result, save_locally, second_opinion, provider }) => {
1855
- const body = { repo_name: REPO_NAME };
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
- description: "Retrieve the assembled reimplement context for a Jira ticket. " +
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 if processing is not yet complete. " +
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 ({ ticket_number, save_locally }) => {
1910
- const resp = await fetch(buildGetUrl(`/ticket/${encodeURIComponent(ticket_number)}/reimplement-context`, { repo_name: REPO_NAME }), { headers: GET_HEADERS });
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
- description: "Insert a ticket into Bridge API's database for lifecycle tracking. This registers the ticket so that workflow state timestamps (critique, clarify, plan, implement) can be tracked. " +
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
- "Call this after creating a ticket with create_ticket to enable state tracking. " +
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: POST_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: POST_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: GET_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: GET_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: POST_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
- description: "Resolve the post-PR target Jira status for the configured repository using an LLM agent. " +
2035
- "The agent selects the workflow status that best represents 'code committed via PR but not yet tested.' " +
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: POST_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: GET_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: GET_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: GET_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([z.string(), z.boolean(), z.array(z.string())]).optional().describe("The new value for the configuration field. Provide either value or file_path, not both. " +
2118
- "Most fields take a string; scalar boolean fields (e.g. allow_mutating_smoke_ops) take true/false. " +
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: POST_HEADERS,
2156
- body: JSON.stringify({ repo_name: REPO_NAME, value: arrayValue }),
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: POST_HEADERS,
2205
- body: JSON.stringify({ repo_name: REPO_NAME, value: boolValue }),
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: POST_HEADERS,
2223
- body: JSON.stringify({ repo_name: REPO_NAME, value: finalValue }),
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
- description: "Submit a deep research request on a technical topic using AI-powered web search. " +
2286
- "Returns a task_id for tracking the research progress. " +
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: POST_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: GET_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: GET_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
- description: "Retrieve the result of a previously submitted deep research request. " +
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 if still processing or failed. " +
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: GET_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: GET_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: GET_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
- const dir = getDocsPath("brainstorm");
2500
- const savedPaths = [];
2501
- for (const row of envelope.results) {
2502
- const markdown = row.markdown;
2503
- if (!markdown) {
2504
- continue;
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
- description: "Submit a brainstorm request that fans out the task to two opinion-provider LLMs " +
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 you can use with get_brainstorm. " +
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 to " +
2554
- "BAPI_DOCS_DIR/brainstorm/{brainstorm_id}-{provider}.md, including {brainstorm_id}-synthesizer.md.",
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: POST_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: GET_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
- savedPaths = await saveBrainstormResultsLocally(envelope);
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
- description: "Retrieve the result envelope for a previously submitted brainstorm by brainstorm_id. " +
2636
- "Returns all rows (opinion providers + synthesizer), including error_kind for every row. " +
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: GET_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: POST_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: POST_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: GET_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: GET_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 must be merged into PIPELINES + INSTRUCTIONS BEFORE
2787
- // the run_pipeline tool is registered so its embedded catalog includes both
2788
- // bundled and user entries. The merge result is also used by list_pipelines
2789
- // and get_pipeline_recipe below every caller must see the same final
2790
- // objects.
2791
- {
2792
- const instructionsDir = path.join(path.dirname(BAPI_PIPELINES_DIR), "instructions");
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: BAPI_DOCS_DIR }] };
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: BAPI_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 buildPipelineCatalogDescription() {
2929
- const lines = [];
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: API_KEY,
3926
+ apiKey: await getResolvedApiKey(),
2941
3927
  repoName: REPO_NAME,
2942
- docsDir: BAPI_DOCS_DIR,
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: API_KEY,
3941
+ apiKey: await getResolvedApiKey(),
2955
3942
  repoName: REPO_NAME,
2956
- docsDir: BAPI_DOCS_DIR,
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
- "Available pipelines:\n" +
2974
- buildPipelineCatalogDescription(),
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
- description: "Generate a local HTML decision page for capturing user decisions on ticket review findings. " +
3155
- "Renders recommendation-driven review decisions sourced from the combined review-and-resolution " +
3156
- "document, with per-option consequence lines, a closed-by-default codebase-evidence disclosure, " +
3157
- "and confirmed improvements. The user opens the HTML file in a browser, makes selections, and " +
3158
- "copies the resulting JSON output back to the agent.",
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 assetsDir = path.join(PROJECT_ROOT, "design-assets");
3210
- const fontsDir = path.join(PROJECT_ROOT, "public", "fonts");
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
- // Compute relative path from output dir to fonts dir
3224
- const docsPath = getDocsPath("review");
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 () => {