@bridge_gpt/mcp-server 0.1.16 → 0.2.0

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