@bridge_gpt/mcp-server 0.2.2 → 0.2.3

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 (113) hide show
  1. package/README.md +97 -15
  2. package/build/agent-config-credential-migration.js +272 -0
  3. package/build/agents.generated.js +1 -1
  4. package/build/chain-orchestrator.js +16 -1
  5. package/build/commands.generated.js +9 -7
  6. package/build/conductor/bridge-api-client.js +625 -0
  7. package/build/conductor/claude-hook.js +251 -0
  8. package/build/conductor/cli.js +1048 -0
  9. package/build/conductor/data-normalization.js +114 -0
  10. package/build/conductor/doctor.js +164 -0
  11. package/build/conductor/done-gate.js +325 -0
  12. package/build/conductor/epic-reconcile.js +139 -0
  13. package/build/conductor/epic-runtime.js +611 -0
  14. package/build/conductor/epic-state.js +125 -0
  15. package/build/conductor/errors.js +85 -0
  16. package/build/conductor/git-ci-types.js +129 -0
  17. package/build/conductor/git-hooks.js +218 -0
  18. package/build/conductor/git-inspection.js +185 -0
  19. package/build/conductor/git-producer.js +137 -0
  20. package/build/conductor/merge-ledger.js +198 -0
  21. package/build/conductor/paths.js +224 -0
  22. package/build/conductor/plan.js +77 -0
  23. package/build/conductor/pr-ci-producer.js +427 -0
  24. package/build/conductor/pr-discovery.js +135 -0
  25. package/build/conductor/producer-ledger.js +125 -0
  26. package/build/conductor/redaction.js +112 -0
  27. package/build/conductor/store.js +1156 -0
  28. package/build/conductor/supervisor-config.js +150 -0
  29. package/build/conductor/supervisor-escalation.js +244 -0
  30. package/build/conductor/supervisor-judgment-python.js +141 -0
  31. package/build/conductor/supervisor-judgment.js +215 -0
  32. package/build/conductor/supervisor-ledger.js +119 -0
  33. package/build/conductor/supervisor-merge.js +127 -0
  34. package/build/conductor/supervisor-message-relay.js +61 -0
  35. package/build/conductor/supervisor-notification.js +39 -0
  36. package/build/conductor/supervisor-runtime.js +351 -0
  37. package/build/conductor/supervisor-state.js +572 -0
  38. package/build/conductor/supervisor-types.js +16 -0
  39. package/build/conductor/taxonomy.js +58 -0
  40. package/build/conductor/tools.js +367 -0
  41. package/build/conductor/types.js +9 -0
  42. package/build/conductor-bin.js +21 -0
  43. package/build/conductor-claude-hook-bin.js +21 -0
  44. package/build/credential-store.js +175 -4
  45. package/build/credentials-cli.js +223 -0
  46. package/build/decision-page-schema.js +60 -0
  47. package/build/decision-page-template.js +262 -10
  48. package/build/doctor.js +5 -1
  49. package/build/index.js +468 -59
  50. package/build/pipeline-orchestrator.js +5 -1
  51. package/build/pipeline-utils.js +45 -5
  52. package/build/pipelines.generated.js +37 -9
  53. package/build/readme.generated.js +1 -1
  54. package/build/review-tickets.js +596 -0
  55. package/build/scheduled-prompt.js +16 -10
  56. package/build/start-tickets-conductor.js +496 -0
  57. package/build/start-tickets-prereqs.js +32 -23
  58. package/build/start-tickets-repo.js +49 -0
  59. package/build/start-tickets.js +682 -81
  60. package/build/version.generated.js +1 -1
  61. package/design-assets/favicon/android-chrome-192x192.png +0 -0
  62. package/design-assets/favicon/android-chrome-512x512.png +0 -0
  63. package/design-assets/favicon/apple-touch-icon.png +0 -0
  64. package/design-assets/favicon/favicon-16x16.png +0 -0
  65. package/design-assets/favicon/favicon-32x32.png +0 -0
  66. package/design-assets/favicon/favicon.ico +0 -0
  67. package/design-assets/favicon/site.webmanifest +1 -0
  68. package/design-assets/just-logo-rough-draft.png +0 -0
  69. package/package.json +17 -5
  70. package/pipelines/idea-to-ticket.json +5 -0
  71. package/pipelines/plan-epic.json +16 -1
  72. package/pipelines/review-ticket.json +2 -1
  73. package/public/css/main.min.css +2 -0
  74. package/public/css/main.min.css.map +1 -0
  75. package/public/fonts/OFL.txt +93 -0
  76. package/public/fonts/SourceSansPro-Black.ttf +0 -0
  77. package/public/fonts/SourceSansPro-BlackItalic.ttf +0 -0
  78. package/public/fonts/SourceSansPro-Bold.ttf +0 -0
  79. package/public/fonts/SourceSansPro-BoldItalic.ttf +0 -0
  80. package/public/fonts/SourceSansPro-ExtraLight.ttf +0 -0
  81. package/public/fonts/SourceSansPro-ExtraLightItalic.ttf +0 -0
  82. package/public/fonts/SourceSansPro-Italic.ttf +0 -0
  83. package/public/fonts/SourceSansPro-Light.ttf +0 -0
  84. package/public/fonts/SourceSansPro-LightItalic.ttf +0 -0
  85. package/public/fonts/SourceSansPro-Regular.ttf +0 -0
  86. package/public/fonts/SourceSansPro-SemiBold.ttf +0 -0
  87. package/public/fonts/SourceSansPro-SemiBoldItalic.ttf +0 -0
  88. package/public/img/bridge-logo-160x51.webp +0 -0
  89. package/public/img/bridge-logo-300x92.webp +0 -0
  90. package/public/img/favicon/android-chrome-192x192.png +0 -0
  91. package/public/img/favicon/android-chrome-512x512.png +0 -0
  92. package/public/img/favicon/apple-touch-icon.png +0 -0
  93. package/public/img/favicon/favicon-16x16.png +0 -0
  94. package/public/img/favicon/favicon-32x32.png +0 -0
  95. package/public/img/favicon/favicon.ico +0 -0
  96. package/public/img/favicon/site.webmanifest +1 -0
  97. package/public/img/installation/bitbucket/app-password-1.png +0 -0
  98. package/public/img/installation/bitbucket/app-password-2.png +0 -0
  99. package/public/img/installation/bitbucket/create-token-1.png +0 -0
  100. package/public/img/installation/bitbucket/create-token-2.png +0 -0
  101. package/public/img/installation/bitbucket/webhook-1.png +0 -0
  102. package/public/img/installation/github/github-review-webhook.png +0 -0
  103. package/public/img/installation/jira/credentials/api-key.png +0 -0
  104. package/public/img/installation/jira/webhook/create-rule.png +0 -0
  105. package/public/img/installation/jira/webhook/project-settings.png +0 -0
  106. package/public/img/installation/jira/webhook/rule-create-1.png +0 -0
  107. package/public/img/installation/jira/webhook/rule-create-2.png +0 -0
  108. package/public/img/installation/jira/webhook/rule-create-3.png +0 -0
  109. package/public/img/installation/pinecone/pinecone-api-key.png +0 -0
  110. package/public/img/installation/pinecone/pinecone-index.png +0 -0
  111. package/public/js/main.min.js +2 -0
  112. package/public/js/main.min.js.map +1 -0
  113. package/smoke-test/SMOKE-TEST.md +16 -8
package/build/index.js CHANGED
@@ -8,11 +8,16 @@
8
8
  * BAPI_REPO_NAME - Default repository name injected into every request
9
9
  * BAPI_API_KEY - API key for X-API-Key authentication
10
10
  * BAPI_DOCS_DIR - Base directory for local file output (default: docs/tmp)
11
+ * BAPI_MCP_UPGRADE_ADVICE_ENABLED - Proactively surface the server's upgrade
12
+ * advice in pipeline recipe preambles (BAPI-375). Defaults to ENABLED; set
13
+ * to false/0/no/off/disabled to suppress proactive recipe-preamble
14
+ * surfacing. Does NOT change the /jira/ping response or server-side upgrade
15
+ * computation — it only gates the recipe-preamble convention.
11
16
  */
12
17
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
18
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
19
  import { z } from "zod";
15
- import { writeFile, mkdir, readFile, stat } from "fs/promises";
20
+ import { writeFile, mkdir, readFile, stat, rename, chmod, unlink } from "fs/promises";
16
21
  import path from "path";
17
22
  import os from "os";
18
23
  import { fileURLToPath } from "url";
@@ -27,13 +32,18 @@ import { checkForUpdate } from "./update-check.js";
27
32
  import { reconstructAgentMarkdown, translateAgentToCopilot } from "./agent-utils.js";
28
33
  import { resolveRecipe, loadCustomPipelines } from "./pipeline-utils.js";
29
34
  import { runStartTicketsCli } from "./start-tickets.js";
35
+ import { runReviewTicketsCli } from "./review-tickets.js";
30
36
  import { runDoctorCli } from "./doctor.js";
31
37
  import { runScheduleRunCli } from "./schedule-run.js";
32
38
  import { runMcpInvokeCli } from "./mcp-invoke.js";
33
39
  import { runAgentCapabilitiesCli } from "./agent-capabilities/cli.js";
34
40
  import { validateRepoName } from "./bridge-config.js";
35
41
  import { ensureGitignored as ensureGitignoredShared } from "./git-ignore-utils.js";
36
- import { resolveBapiCredentials } from "./credential-store.js";
42
+ import { resolveBapiCredentials, upsertBapiCredential, getPrimaryCredentialStorePath, } from "./credential-store.js";
43
+ import { runCredentialsCli } from "./credentials-cli.js";
44
+ import { registerConductorTools } from "./conductor/tools.js";
45
+ import { runConductorCli } from "./conductor/cli.js";
46
+ import { observePrCiFromPollResponse } from "./conductor/pr-ci-producer.js";
37
47
  import { generateDecisionPageHtml } from "./decision-page-template.js";
38
48
  import { DecisionPageInputShape } from "./decision-page-schema.js";
39
49
  import { slugify, saveBrainstormResultsToDir, } from "./brainstorm-files.js";
@@ -48,6 +58,25 @@ let userPipelineKeys = new Set();
48
58
  // ---------------------------------------------------------------------------
49
59
  const BASE_URL = process.env.BAPI_BASE_URL ?? "https://bridgegpt-api.com";
50
60
  const REPO_NAME = process.env.BAPI_REPO_NAME ?? "";
61
+ /**
62
+ * Parse a default-ON boolean env flag. `undefined` / blank → true; the
63
+ * normalized off-tokens (`false`, `0`, `no`, `off`, `disabled`) → false; any
64
+ * other value → true (preserving default-on / fail-open behavior).
65
+ */
66
+ function parseDefaultOnEnvFlag(value) {
67
+ if (value === undefined)
68
+ return true;
69
+ const normalized = value.trim().toLowerCase();
70
+ if (normalized === "")
71
+ return true;
72
+ return !["false", "0", "no", "off", "disabled"].includes(normalized);
73
+ }
74
+ // BAPI-375: gate proactive upgrade-advice surfacing in recipe preambles with an
75
+ // MCP-LOCAL env-var rather than backend config-field plumbing. This flag only
76
+ // controls whether the surfacing convention is emitted; the backend remains
77
+ // authoritative for whether advice exists and for its exact wording
78
+ // (compute_upgrade()). Defaults to enabled; fail-open on any unexpected value.
79
+ const UPGRADE_ADVICE_SURFACING_ENABLED = parseDefaultOnEnvFlag(process.env.BAPI_MCP_UPGRADE_ADVICE_ENABLED);
51
80
  // ---------------------------------------------------------------------------
52
81
  // Resolved-once credential + path accessors (BAPI-338)
53
82
  //
@@ -86,6 +115,45 @@ async function getResolvedApiKey() {
86
115
  }
87
116
  return resolvedApiKeyPromise;
88
117
  }
118
+ /**
119
+ * Resolve the Bridge API key env-first, then from the home-dir credential store,
120
+ * for an EXPLICIT `repoName` (not the module-level `REPO_NAME`). Used by the
121
+ * install-time persistence tool, which targets the repo the user is installing
122
+ * into. Uncached. Returns an empty string on any failure and never logs secrets.
123
+ */
124
+ async function getResolvedApiKeyForRepo(repoName) {
125
+ try {
126
+ const result = await resolveBapiCredentials(repoName, {
127
+ env: process.env,
128
+ homedir: os.homedir,
129
+ platform: process.platform,
130
+ readFile: (p) => readFile(p, "utf-8"),
131
+ stat: (p) => stat(p),
132
+ });
133
+ return result.ok ? result.credentials.apiKey : "";
134
+ }
135
+ catch {
136
+ return "";
137
+ }
138
+ }
139
+ /**
140
+ * Build {@link CredentialStoreWriteDeps} from the local MCP-server runtime
141
+ * boundaries (`process.env`, `os.homedir`, `process.platform`, `fs/promises`).
142
+ * The returned deps are what the writer mutates the user-scoped store through.
143
+ */
144
+ function buildCredentialStoreWriteDeps() {
145
+ return {
146
+ env: process.env,
147
+ homedir: os.homedir,
148
+ platform: process.platform,
149
+ readFile: (p) => readFile(p, "utf-8"),
150
+ mkdir: (p, options) => mkdir(p, options),
151
+ writeFile: (p, data, options) => writeFile(p, data, options),
152
+ rename: (oldPath, newPath) => rename(oldPath, newPath),
153
+ chmod: (p, mode) => chmod(p, mode),
154
+ unlink: (p) => unlink(p),
155
+ };
156
+ }
89
157
  /** GET auth headers, with the API key resolved once via {@link getResolvedApiKey}. */
90
158
  async function getGetHeaders() {
91
159
  return {
@@ -519,9 +587,21 @@ const TICKET_ARTIFACTS = {
519
587
  requestErrorPrefix: "Failed to request FSD generation: ",
520
588
  confirmationText: (n) => `FSD generation requested for ${n}. ` +
521
589
  `Processing typically takes 2-4 minutes. ` +
522
- `Use get_design_doc with ticket_number "${n}" and doc_type "fsd" to retrieve the functional specification document once processing completes.`,
590
+ `Use get_doc with ticket_number "${n}" and doc_type "fsd" to retrieve the functional specification document once processing completes.`,
523
591
  pollLabel: (n) => `FSD generation for ${n}`,
524
592
  },
593
+ prd: {
594
+ kind: "single",
595
+ generateEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/generate-prd`,
596
+ getEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/prd`,
597
+ saveSubdir: "prd",
598
+ filename: (n) => `${n}-prd-plan.md`,
599
+ requestErrorPrefix: "Failed to request PRD generation: ",
600
+ confirmationText: (n) => `PRD generation requested for ${n}. ` +
601
+ `Processing typically takes 2-4 minutes. ` +
602
+ `Use get_prd with ticket_number "${n}" to retrieve the PRD once processing completes.`,
603
+ pollLabel: (n) => `PRD generation for ${n}`,
604
+ },
525
605
  clarifying_questions: {
526
606
  kind: "single",
527
607
  generateEndpoint: (n) => `/ticket/${encodeURIComponent(n)}/generate-clarifying-questions`,
@@ -598,6 +678,7 @@ async function getTicketArtifactDocsPath(subdir) {
598
678
  return getDocsPath("architecture");
599
679
  case "fsd":
600
680
  return getDocsPath("fsd");
681
+ case "prd": return getDocsPath("prd");
601
682
  case "clarifying-questions":
602
683
  return getDocsPath("clarifying-questions");
603
684
  case "ticket-critiques":
@@ -610,11 +691,15 @@ async function getTicketArtifactDocsPath(subdir) {
610
691
  // `tdd` reuses the existing "architecture" artifact (the TDD/architecture
611
692
  // document) WITHOUT renaming it, preserving the back-compatible
612
693
  // request_architecture / get_architecture tools. `fsd` routes to the new "fsd"
613
- // artifact.
694
+ // artifact, and `prd` routes to the new "prd" artifact.
614
695
  function resolveDesignDocArtifactType(docType) {
615
- return docType === "fsd" ? "fsd" : "architecture";
696
+ if (docType === "fsd")
697
+ return "fsd";
698
+ if (docType === "prd")
699
+ return "prd";
700
+ return "architecture";
616
701
  }
617
- // Shared request flow for the five single-artifact request_* tools: POST the
702
+ // Shared request flow for the six single-artifact request_* tools: POST the
618
703
  // generate endpoint, return the per-tool error prefix on a non-OK POST, and on
619
704
  // wait_for_result poll the get endpoint (900_000 ms) and optionally save.
620
705
  async function requestTicketArtifact(type, args) {
@@ -647,7 +732,7 @@ async function requestTicketArtifact(type, args) {
647
732
  content: [{ type: "text", text: config.confirmationText(args.ticket_number) }],
648
733
  };
649
734
  }
650
- // Shared GET/save flow for the five single-artifact get_* tools. Normal gets
735
+ // Shared GET/save flow for the six single-artifact get_* tools. Normal gets
651
736
  // save only on `resp.ok && save_locally`. reimplement_context is asymmetric: it
652
737
  // short-circuits a 404 with a custom NOT_FOUND envelope (without leaking the
653
738
  // backend body) and otherwise saves on ANY non-404 response when save_locally.
@@ -1286,6 +1371,9 @@ async function dispatchCliSubcommand(argv) {
1286
1371
  if (argv[0] === "start-tickets") {
1287
1372
  return runStartTicketsCli(argv.slice(1));
1288
1373
  }
1374
+ if (argv[0] === "review-tickets") {
1375
+ return runReviewTicketsCli(argv.slice(1));
1376
+ }
1289
1377
  // The internal `mcp-invoke` worktree shim (BAPI-337) is a positional
1290
1378
  // subcommand routed before the flag guards and well before MCP server
1291
1379
  // construction: it resolves identity/credentials from `--project-root` and
@@ -1311,6 +1399,21 @@ async function dispatchCliSubcommand(argv) {
1311
1399
  if (argv[0] === "agent-capabilities") {
1312
1400
  return runAgentCapabilitiesCli(argv.slice(1));
1313
1401
  }
1402
+ // The `credentials` subcommand (BAPI-377) hosts the consent-gated agent-config
1403
+ // credential migration. It is a positional subcommand routed before the
1404
+ // --init / --upgrade flag guards and well before MCP server construction so
1405
+ // migration never falls through to the normal no-subcommand startup path. It
1406
+ // is the ONLY place credential migration lives — doctor stays strictly read-only.
1407
+ if (argv[0] === "credentials") {
1408
+ return runCredentialsCli(argv.slice(1));
1409
+ }
1410
+ // The `conductor` subcommand (BAPI-393) is the local event-ledger CLI for fast
1411
+ // hooks and operator diagnostics. It is a positional subcommand routed before
1412
+ // the --init / --upgrade flag guards and well before MCP server construction;
1413
+ // it talks ONLY to the local SQLite ledger and never starts the MCP server.
1414
+ if (argv[0] === "conductor") {
1415
+ return runConductorCli(argv.slice(1));
1416
+ }
1314
1417
  // --init takes precedence over --upgrade; both are position-independent flags.
1315
1418
  if (argv.includes("--init")) {
1316
1419
  return runInitCli(cwd);
@@ -1418,6 +1521,16 @@ const registerTool = ((name, config, handler) => {
1418
1521
  }
1419
1522
  return toolHandle;
1420
1523
  });
1524
+ // ---------------------------------------------------------------------------
1525
+ // Conductor event-ledger tools (BAPI-393)
1526
+ // ---------------------------------------------------------------------------
1527
+ //
1528
+ // The conductor tools (emit_event, poll_events, wait_for_event,
1529
+ // get_supervisor_snapshot) operate against a LOCAL SQLite ledger at
1530
+ // ~/.config/bridge/events.db and intentionally do NOT route through the Bridge
1531
+ // API HTTP helpers. They are registered through the same `registerTool` wrapper
1532
+ // as every other tool so they share enable/disable + in-process dispatch.
1533
+ registerConductorTools(registerTool);
1421
1534
  registerTool("ping", {
1422
1535
  annotations: {
1423
1536
  readOnlyHint: true,
@@ -1462,14 +1575,14 @@ registerTool("ping", {
1462
1575
  });
1463
1576
  registerTool("second_opinion", {
1464
1577
  annotations: {
1465
- readOnlyHint: true,
1578
+ readOnlyHint: false, // handler POSTs and creates a second_opinion_requests row every call
1466
1579
  destructiveHint: false,
1467
- idempotentHint: true,
1580
+ idempotentHint: false, // each call submits a fresh LLM request
1468
1581
  openWorldHint: true,
1469
1582
  },
1470
1583
  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. " +
1471
1584
  "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. " +
1472
- "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. " +
1585
+ "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 persists only an internal request-tracking row, no retrievable artifact. It simply returns the other model's reply to your prompt right now. " +
1473
1586
  "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. " +
1474
1587
  "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'). " +
1475
1588
  "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. " +
@@ -1490,7 +1603,10 @@ registerTool("second_opinion", {
1490
1603
  .describe("Model tier within the chosen provider. CHEAP_MODEL for quick sanity checks, BASIC_MODEL for focused reviews, PREMIUM_MODEL for serious architectural pushback."),
1491
1604
  },
1492
1605
  }, async ({ prompt, provider, model }) => {
1493
- const resp = await fetch(buildApiUrl("/llm/second-opinion"), {
1606
+ // The backend is async (submit -> poll -> result) so a slow second opinion
1607
+ // no longer exceeds the server's 30s request timeout (H12). This tool
1608
+ // absorbs the polling so the agent still gets the reply in a single call.
1609
+ const submitResp = await fetch(buildApiUrl("/llm/second-opinion"), {
1494
1610
  method: "POST",
1495
1611
  headers: await getPostHeaders(),
1496
1612
  body: JSON.stringify({
@@ -1500,7 +1616,65 @@ registerTool("second_opinion", {
1500
1616
  model,
1501
1617
  }),
1502
1618
  });
1503
- const text = await handleResponse(resp);
1619
+ if (!submitResp.ok) {
1620
+ const text = await handleResponse(submitResp);
1621
+ return { content: [{ type: "text", text }] };
1622
+ }
1623
+ const submitBody = (await submitResp.json());
1624
+ const requestId = submitBody.request_id;
1625
+ if (typeof requestId !== "number") {
1626
+ return {
1627
+ content: [
1628
+ {
1629
+ type: "text",
1630
+ text: JSON.stringify({ error: "Second opinion submit response is missing request_id", status: 500 }),
1631
+ },
1632
+ ],
1633
+ };
1634
+ }
1635
+ const repoQuery = `repo_name=${encodeURIComponent(REPO_NAME)}`;
1636
+ const statusUrl = buildApiUrl(`/llm/second-opinion/${requestId}/status?${repoQuery}`);
1637
+ const resultUrl = buildApiUrl(`/llm/second-opinion/${requestId}/result?${repoQuery}`);
1638
+ // Poll /status until terminal. A second opinion is typically 20-90s but can
1639
+ // approach the server's 240s tool-loop deadline; start at 3s, back off to 8s
1640
+ // after 30s, cap at 300s. The server-side janitor fails truly-stuck rows
1641
+ // only after 15 min, so this cap always returns the recoverable message well
1642
+ // before a live row is touched.
1643
+ const startTime = Date.now();
1644
+ const timeoutMs = 300_000;
1645
+ let pollIntervalMs = 3_000;
1646
+ let finalStatus = "";
1647
+ while (true) {
1648
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
1649
+ if (Date.now() - startTime >= timeoutMs) {
1650
+ return {
1651
+ content: [
1652
+ {
1653
+ type: "text",
1654
+ text: `Second opinion is still processing after ${Math.round(timeoutMs / 1000)}s (request_id=${requestId}). The result is recoverable from the server via GET /llm/second-opinion/${requestId}/result?${repoQuery} once it finishes.`,
1655
+ },
1656
+ ],
1657
+ };
1658
+ }
1659
+ if (Date.now() - startTime > 30_000) {
1660
+ pollIntervalMs = 8_000;
1661
+ }
1662
+ const statusResp = await fetch(statusUrl, { headers: await getGetHeaders() });
1663
+ if (!statusResp.ok) {
1664
+ const text = await handleResponse(statusResp);
1665
+ return { content: [{ type: "text", text }] };
1666
+ }
1667
+ const statusBody = (await statusResp.json());
1668
+ finalStatus = typeof statusBody.status === "string" ? statusBody.status : "";
1669
+ if (finalStatus === "completed" || finalStatus === "failed") {
1670
+ break;
1671
+ }
1672
+ }
1673
+ // Both terminal states are served by /result: completed returns the
1674
+ // {response, provider, tier, model} JSON; failed returns the sanitized 409
1675
+ // failure detail. handleResponse preserves the prior single-call output shape.
1676
+ const resultResp = await fetch(resultUrl, { headers: await getGetHeaders() });
1677
+ const text = await handleResponse(resultResp);
1504
1678
  return { content: [{ type: "text", text }] };
1505
1679
  });
1506
1680
  registerTool("generate_image", {
@@ -1892,11 +2066,10 @@ registerTool("get_plan", {
1892
2066
  idempotentHint: true,
1893
2067
  openWorldHint: true,
1894
2068
  },
1895
- 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. " +
2069
+ description: "RETRIEVE an already-generated implementation plan for a Jira ticket as markdown. This tool only fetches an existing plan — it does NOT start or trigger plan generation. " +
1896
2070
  "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. " +
1897
- "Returns the full plan as markdown text — present it verbatim without summarizing. " +
1898
- "The plan includes step-by-step implementation guidance with code file references. " +
1899
- "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. " +
2071
+ "Returns the full plan as markdown verbatim — present it without summarizing. " +
2072
+ "Returns a 404 / not-found response when no plan is ready yet — that means generation has not run, not that this tool failed. " +
1900
2073
  "Tip: call get_clarifying_questions for the same ticket to get the full context for implementation.",
1901
2074
  inputSchema: {
1902
2075
  ticket_number: z
@@ -1906,7 +2079,7 @@ registerTool("get_plan", {
1906
2079
  .boolean()
1907
2080
  .optional()
1908
2081
  .default(true)
1909
- .describe("Whether to save the plan to a local file in the BAPI_DOCS_DIR/plans/ directory. " +
2082
+ .describe("Whether to save the retrieved plan to a local file. Saves to BAPI_DOCS_DIR/plans/{ticket}-plan.md. " +
1910
2083
  "Defaults to true. Set to false to skip saving."),
1911
2084
  },
1912
2085
  }, async (args) => {
@@ -1938,6 +2111,32 @@ registerTool("get_architecture", {
1938
2111
  }, async (args) => {
1939
2112
  return getTicketArtifact("architecture", args);
1940
2113
  });
2114
+ registerTool("get_prd", {
2115
+ annotations: {
2116
+ readOnlyHint: true,
2117
+ destructiveHint: false,
2118
+ idempotentHint: true,
2119
+ openWorldHint: true,
2120
+ },
2121
+ description: "RETRIEVE an already-generated Product Requirements Document (PRD) for a Jira ticket. This tool only fetches an existing PRD — it does NOT start or trigger generation. " +
2122
+ "If no PRD exists yet (or you need a fresh one), call `request_prd` first; it starts the async generation and this `get_prd` tool retrieves the result. " +
2123
+ "Returns the full PRD as markdown text — present it verbatim without summarizing. " +
2124
+ "The PRD is product/stakeholder-facing: problem framing, goals, success metrics, scope, and product requirements. " +
2125
+ "Returns a 404 / not-found response when no PRD is ready yet — that means generation has not run, not that this tool failed.",
2126
+ inputSchema: {
2127
+ ticket_number: z
2128
+ .string()
2129
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123, BAPI-42)"),
2130
+ save_locally: z
2131
+ .boolean()
2132
+ .optional()
2133
+ .default(true)
2134
+ .describe("Whether to save the PRD to a local file in the BAPI_DOCS_DIR/prd/ directory. " +
2135
+ "Defaults to true. Set to false to skip saving."),
2136
+ },
2137
+ }, async (args) => {
2138
+ return getTicketArtifact("prd", args);
2139
+ });
1941
2140
  registerTool("get_clarifying_questions", {
1942
2141
  annotations: {
1943
2142
  readOnlyHint: true,
@@ -2155,7 +2354,7 @@ registerTool("upload_attachment", {
2155
2354
  "Optionally syncs the content to Bridge API's tickets_links table so retrieval endpoints " +
2156
2355
  "(get_clarifying_questions, get_ticket_critique, get_plan) return the updated content without re-generation. " +
2157
2356
  "Use link_type to specify which retrieval endpoint should serve this content. " +
2158
- "Known link_type values: clarifying-questions.md, debugging-guidance.md, ticket-quality-critique.md, architecture-plan.md, fsd-plan.md.",
2357
+ "Known link_type values: clarifying-questions.md, debugging-guidance.md, ticket-quality-critique.md, architecture-plan.md, fsd-plan.md, prd-plan.md.",
2159
2358
  inputSchema: {
2160
2359
  ticket_number: z
2161
2360
  .string()
@@ -2180,7 +2379,7 @@ registerTool("upload_attachment", {
2180
2379
  .string()
2181
2380
  .optional()
2182
2381
  .describe("When provided, also syncs the content to Bridge API's tickets_links table. " +
2183
- "Known values: clarifying-questions.md, debugging-guidance.md, ticket-quality-critique.md, architecture-plan.md. " +
2382
+ "Known values: clarifying-questions.md, debugging-guidance.md, ticket-quality-critique.md, architecture-plan.md, fsd-plan.md, prd-plan.md. " +
2184
2383
  "Unknown values are accepted with a warning."),
2185
2384
  replace_existing: z
2186
2385
  .boolean()
@@ -2438,7 +2637,55 @@ registerTool("request_architecture", {
2438
2637
  }, async (args) => {
2439
2638
  return requestTicketArtifact("architecture", args);
2440
2639
  });
2441
- registerTool("request_design_doc", {
2640
+ registerTool("request_prd", {
2641
+ annotations: {
2642
+ readOnlyHint: false,
2643
+ destructiveHint: false,
2644
+ idempotentHint: false,
2645
+ openWorldHint: true,
2646
+ },
2647
+ description: "START (or refresh) async generation of a Product Requirements Document (PRD) for a Jira ticket. " +
2648
+ "This triggers an asynchronous background job — results are NOT immediate. " +
2649
+ "Processing typically takes 2-4 minutes depending on ticket complexity. " +
2650
+ "The matching get_prd tool retrieves the generated PRD later (call get_prd with the same ticket_number) — unless you set wait_for_result, in which case this call blocks and returns it directly. " +
2651
+ "Returns 202 if the request was accepted, 404 if the ticket does not exist in Jira, " +
2652
+ "or 403 if the API key is unauthorized. " +
2653
+ "Set wait_for_result to true to block until the result is ready (typically 2-4 minutes) instead of returning immediately.",
2654
+ inputSchema: {
2655
+ ticket_number: z
2656
+ .string()
2657
+ .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123) to generate a PRD for"),
2658
+ wait_for_result: z
2659
+ .boolean()
2660
+ .optional()
2661
+ .default(false)
2662
+ .describe("When true, the tool blocks and polls until the PRD is ready (typically 2-4 minutes), " +
2663
+ "then returns the full PRD content directly. When false (default), returns immediately " +
2664
+ "with a confirmation message — use get_prd later to retrieve results."),
2665
+ save_locally: z
2666
+ .boolean()
2667
+ .optional()
2668
+ .default(true)
2669
+ .describe("When wait_for_result is true, whether to save the PRD to a local file in the " +
2670
+ "BAPI_DOCS_DIR/prd/ directory. Defaults to true. Only takes effect when wait_for_result is true."),
2671
+ second_opinion: z
2672
+ .string()
2673
+ .optional()
2674
+ .describe("Provider routing override for THIS artifact-generation request " +
2675
+ "(e.g. 'anthropic', 'openai', 'gemini'). When set, the artifact is " +
2676
+ "generated by the named provider and, where supported, a cross-provider " +
2677
+ "second-opinion pass is applied to this request only. " +
2678
+ "This is NOT the standalone `second_opinion` tool — it does not return " +
2679
+ "an ad hoc critique; it only changes which provider produces this " +
2680
+ "request's artifact. Takes precedence over `provider` when both are set."),
2681
+ provider: z.string().optional().describe("Pure provider switch — use a specific LLM provider (openai, anthropic, gemini) without " +
2682
+ "triggering second-opinion semantics. If both provider and second_opinion are set, " +
2683
+ "second_opinion takes precedence."),
2684
+ },
2685
+ }, async (args) => {
2686
+ return requestTicketArtifact("prd", args);
2687
+ });
2688
+ registerTool("create_doc", {
2442
2689
  annotations: {
2443
2690
  readOnlyHint: false,
2444
2691
  destructiveHint: false,
@@ -2446,27 +2693,28 @@ registerTool("request_design_doc", {
2446
2693
  openWorldHint: true,
2447
2694
  },
2448
2695
  description: "START (or refresh) async generation of a design document for a Jira ticket, routed by doc_type. " +
2449
- "Use doc_type 'tdd' for a Technical Design Document (architecture-focused, for engineers) or 'fsd' for a " +
2450
- "Functional Specification Document (product/functional-focused, for PMs, designers, and QA). " +
2696
+ "Use doc_type 'tdd' for a Technical Design Document (architecture-focused, for engineers), 'fsd' for a " +
2697
+ "Functional Specification Document (product/functional-focused, for PMs, designers, and QA), or 'prd' for a " +
2698
+ "Product Requirements Document (product-requirements-focused: problem, goals, and success metrics). " +
2451
2699
  "This triggers an asynchronous background job — results are NOT immediate. " +
2452
2700
  "Processing typically takes 2-4 minutes depending on ticket complexity. " +
2453
- "The matching get_design_doc tool retrieves the generated document later (call get_design_doc with the same ticket_number and doc_type) — unless you set wait_for_result, in which case this call blocks and returns it directly. " +
2701
+ "The matching get_doc tool retrieves the generated document later (call get_doc with the same ticket_number and doc_type) — unless you set wait_for_result, in which case this call blocks and returns it directly. " +
2454
2702
  "Returns 202 if the request was accepted, 404 if the ticket does not exist in Jira, or 403 if the API key is unauthorized.",
2455
2703
  inputSchema: {
2456
2704
  ticket_number: z
2457
2705
  .string()
2458
2706
  .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123) to generate a design document for"),
2459
- doc_type: z
2460
- .enum(["tdd", "fsd"])
2461
- .describe("Which design document to generate: 'tdd' (Technical Design Document, engineer audience) or " +
2462
- "'fsd' (Functional Specification Document, product/functional audience)."),
2707
+ doc_type: z.enum(["tdd", "fsd", "prd"])
2708
+ .describe("Which design document to generate: 'tdd' (Technical Design Document, engineer audience), " +
2709
+ "'fsd' (Functional Specification Document, product/functional audience), or " +
2710
+ "'prd' (Product Requirements Document, product-requirements focused: problem, goals, success metrics)."),
2463
2711
  wait_for_result: z
2464
2712
  .boolean()
2465
2713
  .optional()
2466
2714
  .default(false)
2467
2715
  .describe("When true, the tool blocks and polls until the document is ready (typically 2-4 minutes), " +
2468
2716
  "then returns the full content directly. When false (default), returns immediately " +
2469
- "with a confirmation message — use get_design_doc later to retrieve results."),
2717
+ "with a confirmation message — use get_doc later to retrieve results."),
2470
2718
  save_locally: z
2471
2719
  .boolean()
2472
2720
  .optional()
@@ -2488,7 +2736,7 @@ registerTool("request_design_doc", {
2488
2736
  }, async (args) => {
2489
2737
  return requestTicketArtifact(resolveDesignDocArtifactType(args.doc_type), args);
2490
2738
  });
2491
- registerTool("get_design_doc", {
2739
+ registerTool("get_doc", {
2492
2740
  annotations: {
2493
2741
  readOnlyHint: true,
2494
2742
  destructiveHint: false,
@@ -2496,19 +2744,19 @@ registerTool("get_design_doc", {
2496
2744
  openWorldHint: true,
2497
2745
  },
2498
2746
  description: "RETRIEVE an already-generated design document for a Jira ticket, routed by doc_type. " +
2499
- "Use doc_type 'tdd' for the Technical Design Document or 'fsd' for the Functional Specification Document. " +
2747
+ "Use doc_type 'tdd' for the Technical Design Document, 'fsd' for the Functional Specification Document, or " +
2748
+ "'prd' for the Product Requirements Document. " +
2500
2749
  "This tool only fetches an existing document — it does NOT start or trigger generation. " +
2501
- "If no document exists yet (or you need a fresh one), call `request_design_doc` first with the same doc_type. " +
2750
+ "If no document exists yet (or you need a fresh one), call `create_doc` first with the same doc_type. " +
2502
2751
  "Returns the full document as markdown text — present it verbatim without summarizing. " +
2503
2752
  "Returns a 404 / not-found response when no document is ready yet — that means generation has not run, not that this tool failed.",
2504
2753
  inputSchema: {
2505
2754
  ticket_number: z
2506
2755
  .string()
2507
2756
  .describe("Jira ticket key in PROJECT-NUMBER format (e.g. PROJ-123, BAPI-42)"),
2508
- doc_type: z
2509
- .enum(["tdd", "fsd"])
2510
- .describe("Which design document to retrieve: 'tdd' (Technical Design Document) or " +
2511
- "'fsd' (Functional Specification Document)."),
2757
+ doc_type: z.enum(["tdd", "fsd", "prd"])
2758
+ .describe("Which design document to retrieve: 'tdd' (Technical Design Document), " +
2759
+ "'fsd' (Functional Specification Document), or 'prd' (Product Requirements Document)."),
2512
2760
  save_locally: z
2513
2761
  .boolean()
2514
2762
  .optional()
@@ -2941,7 +3189,7 @@ registerTool("resolve_target_status", {
2941
3189
  // ---------------------------------------------------------------------------
2942
3190
  const VALID_CONFIG_FIELDS = [
2943
3191
  "review_instructions", "documentation_instructions", "architecture_instructions",
2944
- "tdd_document_instructions", "fsd_document_instructions",
3192
+ "tdd_document_instructions", "fsd_document_instructions", "prd_document_instructions",
2945
3193
  "unit_testing_instructions", "e2e_testing_instructions",
2946
3194
  "unit_testing_stack", "e2e_testing_stack",
2947
3195
  "frontend_correctness_standards", "backend_correctness_standards",
@@ -2950,6 +3198,7 @@ const VALID_CONFIG_FIELDS = [
2950
3198
  "post_pr_target_status", "ci_check_config", "ci_followup_config",
2951
3199
  "allow_mutating_smoke_ops",
2952
3200
  "selected_mcp_slugs",
3201
+ "split_review_reorder_enabled",
2953
3202
  "base_branch",
2954
3203
  "difficulty_model_routing_enabled",
2955
3204
  "difficulty_model_tier_overrides",
@@ -3124,7 +3373,7 @@ registerTool("update_config_field", {
3124
3373
  }
3125
3374
  // Scalar boolean config fields: reject file-path updates and normalize boolean
3126
3375
  // true/false and string "true"/"false" to a real boolean before persisting.
3127
- const BOOLEAN_CONFIG_FIELDS = ["allow_mutating_smoke_ops", "difficulty_model_routing_enabled"];
3376
+ const BOOLEAN_CONFIG_FIELDS = ["allow_mutating_smoke_ops", "difficulty_model_routing_enabled", "split_review_reorder_enabled"];
3128
3377
  if (BOOLEAN_CONFIG_FIELDS.includes(field_name)) {
3129
3378
  if (file_path) {
3130
3379
  return {
@@ -3249,6 +3498,99 @@ registerTool("apply_install_manifest", {
3249
3498
  const text = await handleResponse(resp);
3250
3499
  return { content: [{ type: "text", text }] };
3251
3500
  });
3501
+ registerTool("persist_routing_credential", {
3502
+ annotations: {
3503
+ readOnlyHint: false,
3504
+ destructiveHint: false,
3505
+ idempotentHint: true,
3506
+ openWorldHint: false,
3507
+ },
3508
+ description: "Persist the ALREADY-VALIDATED Bridge API key for this repo into the user-scoped credential " +
3509
+ "store (`~/.config/bridge/credentials.json`) under the target `bapi:<repo_name>`, so that " +
3510
+ "Bash-spawned CLI features such as `start-tickets` (a different runtime surface than the MCP " +
3511
+ "server) can resolve it for difficulty→model routing. This is the final stage of `/install-bridge`. " +
3512
+ "The key is resolved INSIDE the MCP server process (env-first, then the existing store) using the " +
3513
+ "provided `repo_name` as the store identity — it is NEVER passed as a tool argument. Existing " +
3514
+ "credentials are preserved; only `BAPI_API_KEY` for this repo is upserted. The response is " +
3515
+ "secret-free (it reports ok/action/target/path only) and never echoes the key value.",
3516
+ inputSchema: {
3517
+ repo_name: z
3518
+ .string()
3519
+ .describe("The repository name to store the routing credential under (target `bapi:<repo_name>`). " +
3520
+ "This is the ONLY input — do not pass the API key, a secret, or a token; the key is " +
3521
+ "resolved inside the MCP server process."),
3522
+ },
3523
+ }, async ({ repo_name }) => {
3524
+ const repoName = typeof repo_name === "string" ? repo_name.trim() : "";
3525
+ const deps = buildCredentialStoreWriteDeps();
3526
+ const storePath = getPrimaryCredentialStorePath(deps);
3527
+ if (repoName.length === 0) {
3528
+ return {
3529
+ content: [
3530
+ {
3531
+ type: "text",
3532
+ text: JSON.stringify({
3533
+ ok: false,
3534
+ message: "Cannot persist routing credential: repo_name is required. Pass the repo name " +
3535
+ "this install is configuring.",
3536
+ path: storePath,
3537
+ }),
3538
+ },
3539
+ ],
3540
+ };
3541
+ }
3542
+ const target = `bapi:${repoName}`;
3543
+ const apiKey = await getResolvedApiKeyForRepo(repoName);
3544
+ if (apiKey.length === 0) {
3545
+ return {
3546
+ content: [
3547
+ {
3548
+ type: "text",
3549
+ text: JSON.stringify({
3550
+ ok: false,
3551
+ target,
3552
+ path: storePath,
3553
+ message: `No BAPI_API_KEY could be resolved for ${target}. Set BAPI_API_KEY in the ` +
3554
+ `environment (or add it under ${target} in ${storePath}) and rerun /install-bridge.`,
3555
+ }),
3556
+ },
3557
+ ],
3558
+ };
3559
+ }
3560
+ const result = await upsertBapiCredential(repoName, apiKey, deps);
3561
+ if (!result.ok) {
3562
+ return {
3563
+ content: [
3564
+ {
3565
+ type: "text",
3566
+ text: JSON.stringify({
3567
+ ok: false,
3568
+ target: result.target,
3569
+ path: result.path,
3570
+ kind: result.kind,
3571
+ message: `Failed to persist routing credential for ${result.target}: ${result.error} ` +
3572
+ `You can rerun /install-bridge or migrate manually.`,
3573
+ }),
3574
+ },
3575
+ ],
3576
+ };
3577
+ }
3578
+ return {
3579
+ content: [
3580
+ {
3581
+ type: "text",
3582
+ text: JSON.stringify({
3583
+ ok: true,
3584
+ action: result.action,
3585
+ target: result.target,
3586
+ path: result.path,
3587
+ migratedFallback: result.migratedFallback,
3588
+ message: `Stored routing credential for ${result.target} at ${result.path}.`,
3589
+ }),
3590
+ },
3591
+ ],
3592
+ };
3593
+ });
3252
3594
  function formatDeepResearchProviderReason(meta) {
3253
3595
  if (!meta)
3254
3596
  return "";
@@ -3568,21 +3910,23 @@ registerTool("request_brainstorm", {
3568
3910
  openWorldHint: true,
3569
3911
  },
3570
3912
  description: "START an async brainstorm that fans out the task to two opinion-provider LLMs " +
3571
- "(default: OpenAI + Gemini) and then runs a synthesizer pass over the completed opinions. " +
3913
+ "(default: OpenAI + Gemini) and returns each provider's opinion directly (no synthesizer pass). " +
3572
3914
  "Returns a brainstorm_id; the matching get_brainstorm tool retrieves the finished result later (unless you set wait_for_result). " +
3573
3915
  "\n\n" +
3574
3916
  "BEHAVIOR: By default, returns immediately with a brainstorm_id. Set wait_for_result=true " +
3575
3917
  "to poll for terminal status (up to 15 minutes), then retrieve and optionally save the result. " +
3576
3918
  "When save_locally=true (default), each provider's markdown is written with a semantic " +
3577
3919
  "filename derived from task_description: " +
3578
- "BAPI_DOCS_DIR/brainstorm/{slugified-task-description}-{short_brainstorm_id}-{provider}.md, " +
3579
- "and the synthesizer row follows the same pattern " +
3580
- "({slugified-task-description}-{short_brainstorm_id}-synthesizer.md). " +
3920
+ "BAPI_DOCS_DIR/brainstorm/{slugified-task-description}-{short_brainstorm_id}-{provider}.md. " +
3581
3921
  "(If task_description is empty or slugifies to nothing, it falls back to {brainstorm_id}-{provider}.md.) " +
3582
3922
  "\n\n" +
3583
- "DESIGN MODE: Set design=true to run the brainstorm as web-page/UI design ideation focused on " +
3584
- "visual appeal and conversion, rather than the default software-engineering framing. Omit the " +
3585
- "design field unless you want design mode (absent is treated as false).",
3923
+ "MODES: Set mode to pick the brainstorm style. 'technical' (default) runs the implementation/" +
3924
+ "architecture brainstorm. 'design' runs web-page/UI design ideation focused on visual appeal and " +
3925
+ "conversion. 'discovery' generates stakeholder discovery questions for early/vague tasks, grouped " +
3926
+ "into 'Technical Discovery Questions' and 'Business / Stakeholder Discovery Questions' and tagged " +
3927
+ "[HUMAN], [CODE], or [TICKET]; discovery needs no extra configuration. " +
3928
+ "The legacy boolean design=true still works and maps to mode='design'; prefer the explicit mode " +
3929
+ "selector for new callers.",
3586
3930
  inputSchema: {
3587
3931
  task_description: z.string().describe("Free-form description of the task to brainstorm about. Sent verbatim — " +
3588
3932
  "this tool does NOT read task_description from a file."),
@@ -3590,7 +3934,8 @@ registerTool("request_brainstorm", {
3590
3934
  ticket_number: z.string().optional().describe("Optional Jira ticket key (e.g. PROJ-123) to associate with the brainstorm. " +
3591
3935
  "Ticket 1 only stores this for cross-reference — no Jira writes happen."),
3592
3936
  providers: z.array(z.string()).optional().describe("Opinion-provider LLMs. Defaults to ['openai', 'gemini']. " +
3593
- "A single-provider request still inserts a synthesizer row but pre-skips it."),
3937
+ "A single-provider request runs one opinion provider and returns that " +
3938
+ "provider's markdown directly."),
3594
3939
  concerns: z.string().optional().describe("Optional caller-supplied concerns to surface to the brainstorm agents."),
3595
3940
  wait_for_result: z.boolean().optional().describe("When true, polls until every row reaches a terminal status (max 15 minutes), " +
3596
3941
  "then returns the full result envelope. When false (default), returns immediately."),
@@ -3598,11 +3943,17 @@ registerTool("request_brainstorm", {
3598
3943
  "after the result is fetched. Request-time saves use semantic filenames derived from " +
3599
3944
  "task_description (slugified) plus a short brainstorm-id segment."),
3600
3945
  prior_brainstorm_id: z.string().optional().describe("Optional brainstorm_id from an earlier brainstorm to refine. " +
3601
- "When provided, the new brainstorm receives the prior brainstorm's " +
3602
- "synthesizer markdown, or a completed opinion-provider fallback, as prior context."),
3603
- 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."),
3946
+ "When provided, the prior brainstorm's completed opinion-provider " +
3947
+ "markdowns are concatenated and supplied as prior context."),
3948
+ mode: z.enum(["technical", "design", "discovery"]).optional().describe("Preferred brainstorm-mode selector for new callers. 'technical' (default) is the " +
3949
+ "implementation/architecture brainstorm; 'design' is web-page/UI visual-direction ideation; " +
3950
+ "'discovery' generates grouped technical and business/stakeholder discovery questions for " +
3951
+ "early/vague tasks. Takes precedence over the legacy boolean design field."),
3952
+ design: z.boolean().optional().describe("Legacy compatibility flag: set to true for web-page/UI design ideation focused on visual appeal " +
3953
+ "and conversion. New callers should use mode: \"design\" instead. Omit this field when not " +
3954
+ "requesting design mode; absent is treated as false."),
3604
3955
  },
3605
- }, async ({ task_description, repo_name, ticket_number, providers, concerns, wait_for_result, save_locally, prior_brainstorm_id, design, }) => {
3956
+ }, async ({ task_description, repo_name, ticket_number, providers, concerns, wait_for_result, save_locally, prior_brainstorm_id, mode, design, }) => {
3606
3957
  const effectiveRepo = repo_name && repo_name.length > 0 ? repo_name : REPO_NAME;
3607
3958
  const effectiveProviders = providers !== undefined ? providers : ["openai", "gemini"];
3608
3959
  const shouldWait = wait_for_result === true;
@@ -3619,6 +3970,14 @@ registerTool("request_brainstorm", {
3619
3970
  if (prior_brainstorm_id) {
3620
3971
  submitPayload.prior_brainstorm_request_id = prior_brainstorm_id;
3621
3972
  }
3973
+ // Forward `mode` only when the caller explicitly set one, mirroring the
3974
+ // legacy `design` handling below. Leaving it absent lets the backend apply
3975
+ // its own precedence (legacy `design` → prior-row inheritance → technical),
3976
+ // which is required for a design/discovery refinement to keep its mode.
3977
+ if (mode)
3978
+ submitPayload.mode = mode;
3979
+ // Preserve the legacy boolean mapping so a backend that only reads `design`
3980
+ // still behaves correctly.
3622
3981
  if (design)
3623
3982
  submitPayload.design = true;
3624
3983
  const submitResp = await fetch(buildUrl("/brainstorms"), {
@@ -3630,11 +3989,13 @@ registerTool("request_brainstorm", {
3630
3989
  const errorText = await handleResponse(submitResp);
3631
3990
  return { content: [{ type: "text", text: errorText }] };
3632
3991
  }
3992
+ // ``synthesizer_status`` is a temporary backend compatibility sentinel
3993
+ // (always "removed"); it is optional here and never drives control flow.
3633
3994
  const submitBody = (await submitResp.json());
3634
3995
  if (!shouldWait) {
3635
3996
  const confirmation = `Brainstorm submitted (brainstorm_id: ${submitBody.brainstorm_id}). ` +
3636
3997
  `Providers: ${submitBody.providers.join(", ")}. ` +
3637
- `Synthesizer status: ${submitBody.synthesizer_status}. ` +
3998
+ `Synthesis step: removed; provider opinions will be returned directly. ` +
3638
3999
  `Use get_brainstorm with brainstorm_id ${submitBody.brainstorm_id} to retrieve results.`;
3639
4000
  return { content: [{ type: "text", text: confirmation }] };
3640
4001
  }
@@ -3677,9 +4038,9 @@ registerTool("get_brainstorm", {
3677
4038
  },
3678
4039
  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. " +
3679
4040
  "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. " +
3680
- "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. " +
4041
+ "Returns opinion-provider rows only, including error_kind for every row; a not-found / still-processing response means the brainstorm has not completed, not that this tool failed. " +
3681
4042
  "When save_locally=true (default), writes each provider's markdown to " +
3682
- "BAPI_DOCS_DIR/brainstorm/{brainstorm_id}-{provider}.md (including the synthesizer file). " +
4043
+ "BAPI_DOCS_DIR/brainstorm/{brainstorm_id}-{provider}.md. " +
3683
4044
  "Retrieval uses this UUID-only filename (not the semantic task-description name that " +
3684
4045
  "request_brainstorm uses) because the original task description is not available in the " +
3685
4046
  "result envelope on retrieval. " +
@@ -3818,6 +4179,23 @@ const pollCiChecksTool = registerTool("poll_ci_checks", {
3818
4179
  });
3819
4180
  const resp = await fetch(url, { headers: await getGetHeaders() });
3820
4181
  const text = await handleResponse(resp);
4182
+ // BAPI-395: opportunistically emit conductor PR/CI/gate events from the
4183
+ // already-fetched poll response. Fully best-effort — the producer validates
4184
+ // that commit_ref binds to the local PR head before emitting, and any failure
4185
+ // is swallowed so this NEVER changes the poll_ci_checks response body.
4186
+ try {
4187
+ const parsed = JSON.parse(text);
4188
+ const looksLikePollStatus = parsed !== null &&
4189
+ typeof parsed === "object" &&
4190
+ !("error" in parsed) &&
4191
+ (Array.isArray(parsed.checks) || typeof parsed.all_complete === "boolean");
4192
+ if (looksLikePollStatus) {
4193
+ void observePrCiFromPollResponse(commit_ref, parsed).catch(() => { });
4194
+ }
4195
+ }
4196
+ catch {
4197
+ /* non-JSON or non-poll response — nothing to produce */
4198
+ }
3821
4199
  return { content: [{ type: "text", text }] };
3822
4200
  });
3823
4201
  // ---------------------------------------------------------------------------
@@ -3973,7 +4351,7 @@ registerTool("get_pipeline_recipe", {
3973
4351
  if ("idea" in mergedVariables) {
3974
4352
  mergedVariables.idea_hash = deriveIdeaHash(mergedVariables.idea);
3975
4353
  }
3976
- const recipe = resolveRecipe(pipelineDef, INSTRUCTIONS, mergedVariables, skip_steps, !!auto_approve);
4354
+ const recipe = resolveRecipe(pipelineDef, INSTRUCTIONS, mergedVariables, skip_steps, !!auto_approve, { includeUpgradeAdviceSurfacing: UPGRADE_ADVICE_SURFACING_ENABLED });
3977
4355
  return {
3978
4356
  content: [{
3979
4357
  type: "text",
@@ -4015,6 +4393,7 @@ async function buildPipelineOrchestratorDeps() {
4015
4393
  pipelines: PIPELINES,
4016
4394
  instructions: INSTRUCTIONS,
4017
4395
  toolHandlers: TOOL_HANDLERS,
4396
+ includeUpgradeAdviceSurfacing: UPGRADE_ADVICE_SURFACING_ENABLED,
4018
4397
  };
4019
4398
  }
4020
4399
  // BAPI-326: dependency injection for the full-automation chain orchestrator.
@@ -4031,6 +4410,7 @@ async function buildChainOrchestratorDeps() {
4031
4410
  chainRecipes: CHAIN_RECIPES,
4032
4411
  instructions: INSTRUCTIONS,
4033
4412
  toolHandlers: TOOL_HANDLERS,
4413
+ includeUpgradeAdviceSurfacing: UPGRADE_ADVICE_SURFACING_ENABLED,
4034
4414
  };
4035
4415
  }
4036
4416
  registerTool("run_pipeline", {
@@ -4376,7 +4756,11 @@ registerTool("generate_decision_page", {
4376
4756
  "original-question and closed-by-default codebase-evidence display, and an optional confirmed-" +
4377
4757
  "improvements list. Presentation labels (title, intro, section/improvements headings) are " +
4378
4758
  "overridable, and the output location under the docs directory is configurable, so automations " +
4379
- "beyond ticket review can reuse it. The user opens the HTML file in a browser, makes selections, " +
4759
+ "beyond ticket review can reuse it. Pass artifact_type=\"pre_ticket_planning\" to additionally " +
4760
+ "render read-only system_goals (business goal, desired end-state, system behavior, classified " +
4761
+ "NFRs) and a read-only implementation_order section for pre-ticket epic/task framing; open NFRs " +
4762
+ "still go in actionable_items so the human can decide them. The default artifact_type " +
4763
+ "\"review_decisions\" is unchanged. The user opens the HTML file in a browser, makes selections, " +
4380
4764
  "and copies the resulting JSON output back to the agent.",
4381
4765
  inputSchema: DecisionPageInputShape,
4382
4766
  }, async (input) => {
@@ -4393,8 +4777,13 @@ registerTool("generate_decision_page", {
4393
4777
  if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(input.ticket_key)) {
4394
4778
  return validationError(`Invalid ticket_key "${input.ticket_key}": must start with a letter and contain only letters, digits, hyphens, or underscores.`);
4395
4779
  }
4396
- // No-decisions fast path: return structured response without writing a file
4397
- if (input.actionable_items.length === 0) {
4780
+ // No-decisions fast path: return structured response without writing a file.
4781
+ // pre_ticket_planning pages still render when they carry read-only goals or an
4782
+ // implementation order even with zero open NFRs, so only short-circuit when
4783
+ // there is genuinely nothing to show. review_decisions behavior is unchanged
4784
+ // (it never sets system_goals/implementation_order).
4785
+ const hasPlanningContent = input.system_goals !== undefined || (input.implementation_order?.length ?? 0) > 0;
4786
+ if (input.actionable_items.length === 0 && !hasPlanningContent) {
4398
4787
  return {
4399
4788
  content: [{
4400
4789
  type: "text",
@@ -4436,10 +4825,27 @@ registerTool("generate_decision_page", {
4436
4825
  if (!outputTarget.ok) {
4437
4826
  return validationError(outputTarget.message);
4438
4827
  }
4439
- // Read design assets and base64-encode for embedding
4828
+ // Read design assets and base64-encode for embedding.
4829
+ // Prefer the project root (local dev); fall back to the installed package root
4830
+ // (consumer projects where design-assets/ isn't part of the project tree).
4440
4831
  const projectRootForAssets = await getProjectRoot();
4441
- const assetsDir = path.join(projectRootForAssets, "design-assets");
4442
- const fontsDir = path.join(projectRootForAssets, "public", "fonts");
4832
+ const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../");
4833
+ let assetsDir;
4834
+ try {
4835
+ await stat(path.join(projectRootForAssets, "design-assets"));
4836
+ assetsDir = path.join(projectRootForAssets, "design-assets");
4837
+ }
4838
+ catch {
4839
+ assetsDir = path.join(pkgRoot, "design-assets");
4840
+ }
4841
+ let fontsDir;
4842
+ try {
4843
+ await stat(path.join(projectRootForAssets, "public", "fonts"));
4844
+ fontsDir = path.join(projectRootForAssets, "public", "fonts");
4845
+ }
4846
+ catch {
4847
+ fontsDir = path.join(pkgRoot, "public", "fonts");
4848
+ }
4443
4849
  let faviconBase64 = "";
4444
4850
  let logoBase64 = "";
4445
4851
  try {
@@ -4470,8 +4876,11 @@ registerTool("generate_decision_page", {
4470
4876
  text: JSON.stringify({
4471
4877
  status: "decision_page_generated",
4472
4878
  file_path: filePath,
4879
+ artifact_type: input.artifact_type,
4473
4880
  actionable_items_count: input.actionable_items.length,
4474
4881
  clear_improvements_count: input.clear_improvements.length,
4882
+ system_goals_nfr_count: input.system_goals?.nfrs?.length ?? 0,
4883
+ implementation_order_count: input.implementation_order?.length ?? 0,
4475
4884
  }),
4476
4885
  }],
4477
4886
  };