@bridge_gpt/mcp-server 0.1.17 → 0.2.0

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