@capagents/forgeqe 1.1.4 → 1.1.5

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 (56) hide show
  1. package/HOW_TO_USE.md +46 -4
  2. package/README.md +2 -1
  3. package/browserstack.example.yaml +14 -2
  4. package/docs/user-guide.html +62 -10
  5. package/forgeqe.example.yaml +18 -5
  6. package/package.json +2 -1
  7. package/prompts/README.md +1 -0
  8. package/prompts/brain.md +7 -1
  9. package/prompts/defect-rca.md +43 -0
  10. package/prompts/test-agent.md +12 -2
  11. package/prompts/test-reviewer.md +5 -2
  12. package/prompts/test-rules.md +8 -4
  13. package/py/forgeqe_engine/browser.py +1 -1
  14. package/skills/dotnet-nunit-playwright/SKILL.md +2 -1
  15. package/skills/playwright-typescript/SKILL.md +2 -1
  16. package/src/brain/index.ts +1 -1
  17. package/src/capabilities/registry.ts +34 -6
  18. package/src/cases/bugs.ts +209 -22
  19. package/src/cases/defectHistory.ts +193 -0
  20. package/src/cases/defects.ts +255 -0
  21. package/src/cases/github.ts +162 -0
  22. package/src/cases/goal.ts +1 -1
  23. package/src/cases/index.ts +23 -4
  24. package/src/cases/predict.ts +372 -0
  25. package/src/cases/rca.ts +258 -0
  26. package/src/cases/readiness.ts +38 -1
  27. package/src/cases/repro.ts +202 -0
  28. package/src/cases/types.ts +8 -1
  29. package/src/cli/help.ts +8 -2
  30. package/src/cli/index.ts +36 -7
  31. package/src/code/graph/graph.ts +511 -0
  32. package/src/code/graph/locators.ts +398 -0
  33. package/src/code/graph/parse.ts +439 -0
  34. package/src/code/graph/tools.ts +113 -0
  35. package/src/code/handoff.ts +93 -18
  36. package/src/code/loop.ts +30 -5
  37. package/src/code/profile.ts +14 -3
  38. package/src/code/replay.ts +1 -1
  39. package/src/code/review.ts +22 -0
  40. package/src/config.ts +142 -18
  41. package/src/mcp/attachtools.ts +223 -0
  42. package/src/mcp/commands.ts +1 -1
  43. package/src/mcp/defecttools.ts +106 -0
  44. package/src/mcp/devicetools.ts +125 -0
  45. package/src/mcp/external.ts +42 -1
  46. package/src/mcp/tools.ts +92 -9
  47. package/src/monitor/run.ts +1 -1
  48. package/src/report/data.ts +14 -0
  49. package/src/report/html.ts +15 -3
  50. package/src/report/template.html +44 -43
  51. package/src/run.ts +4 -2
  52. package/src/suite/report.ts +44 -11
  53. package/src/suite/run.ts +163 -16
  54. package/src/tui/App.tsx +9 -3
  55. package/src/usage.ts +1 -1
  56. package/testmu.example.yaml +11 -0
package/src/config.ts CHANGED
@@ -38,6 +38,13 @@ export type BrowserRemoteConfig = {
38
38
  extra?: Record<string, unknown>
39
39
  }
40
40
 
41
+ /** A named remote browser (devices: in a provider file): the provider's shared remote settings with its own on top. */
42
+ export type BrowserDevice = {
43
+ name: string
44
+ provider: RemoteBrowserProvider
45
+ remote: BrowserRemoteConfig
46
+ }
47
+
41
48
  export type BrowserConfig = {
42
49
  provider: BrowserProvider
43
50
  headless: boolean
@@ -51,6 +58,8 @@ export type BrowserConfig = {
51
58
  remote?: BrowserRemoteConfig
52
59
  remotes: BrowserRemoteConfigs
53
60
  providerFiles: BrowserProviderFiles
61
+ /** Named devices from the provider files, by name; names are unique across providers. */
62
+ devices: Record<string, BrowserDevice>
54
63
  /** ForgeQE overlay in a visible browser: edge glow, a bar with the current step, the person's input locked. */
55
64
  overlay: boolean
56
65
  /** A second model call checks the finished run against the goal, and its verdict can fail a run the agent completed. */
@@ -78,6 +87,8 @@ export type CodeConfig = {
78
87
  stallAfter?: number
79
88
  /** Give Scribe a Playwright-driven browser, set up like the run's, switched on once a step fails. */
80
89
  liveBrowser?: boolean
90
+ /** Give Scribe the code graph of the repository (code_find, code_outline, code_element, …) and link the handoff's elements to the page objects that locate them (code.graph, default true). */
91
+ graph?: boolean
81
92
  /** Before Scribe starts, replay the run's steps as a Playwright script and hand over what worked and where it failed. */
82
93
  replay?: boolean
83
94
  /** Model for ForgeQE's agent and Scribe as provider/model, instead of the run's llms.json profile. */
@@ -122,11 +133,16 @@ export type SourcesConfig = {
122
133
  qtest?: { url: string; tokenEnv: string; projectId: number; statuses: { passed: string; failed: string; error: string } }
123
134
  /** File a bug for each failed case, or comment on the open one ForgeQE filed before. */
124
135
  bugs?: BugsConfig
136
+ /**
137
+ * GitHub: issues as test cases and bugs, and GitHub's MCP server for ForgeQE's agent. `repo` (owner/name) is the default
138
+ * for issue references; the token is `tokenEnv`, else the GitHub CLI's sign-in. `toolsets`: the MCP server's tool groups.
139
+ */
140
+ github?: { repo?: string; apiUrl: string; tokenEnv: string; mcp: boolean; toolsets: string[] }
125
141
  }
126
142
 
127
143
  export type BugsConfig = {
128
- system: "jira" | "azure_devops"
129
- /** Jira project key. Azure DevOps uses this, or sources.azure_devops.project. */
144
+ system: "jira" | "azure_devops" | "github"
145
+ /** Jira project key; GitHub owner/repo (default sources.github.repo). Azure DevOps uses this, or sources.azure_devops.project. */
130
146
  project?: string
131
147
  /** Jira issue type or Azure DevOps work item type. */
132
148
  type: string
@@ -135,6 +151,11 @@ export type BugsConfig = {
135
151
  areaPath?: string
136
152
  /** Also file bugs for cases that could not run (status error). */
137
153
  onError: boolean
154
+ /**
155
+ * "defect" (the default): one bug per defect, so cases failing the same way (the same failed request, the same
156
+ * uncaught error) share a bug, each added to it. "case": one bug per failing case.
157
+ */
158
+ groupBy?: "defect" | "case"
138
159
  }
139
160
 
140
161
  /** A site the browser agent can sign in to. Values come from the environment; the agent only sees placeholders. */
@@ -307,6 +328,9 @@ function notifyConfig(raw: unknown): NotifyConfig | undefined {
307
328
  if (unknown.length) throw new Error(`${where}.on: ${unknown.join(", ")} is not an event. Events: ${NOTIFY_EVENTS.join(", ")}.`)
308
329
  return { name, ...alerts, on: on as NotifyEvent[] }
309
330
  })
331
+ // No channels set up, but a Teams webhook in the environment: a "teams" channel for messages the user asks for. It
332
+ // takes no events (on: []), so runs nobody watches never start posting to it unasked.
333
+ if (!channels.length && process.env.TEAMS_WEBHOOK_URL?.trim()) channels.push({ name: "teams", teams: { webhookEnv: "TEAMS_WEBHOOK_URL" }, on: [] })
310
334
  if (!channels.length) return undefined
311
335
  return { channels, ...(n.report_url ? { reportUrl: trimUrl(n.report_url) } : {}) }
312
336
  }
@@ -345,6 +369,8 @@ export type AppConfig = {
345
369
  prompts: { stepInstruction: string; guiderails: string; dirs?: { dir: string; source: string }[] }
346
370
  /** Product knowledge (.forgeqe/knowledge): see src/knowledge.ts. */
347
371
  knowledge?: { dir: string; browserMaxChars: number }
372
+ /** Defect intelligence (defects: in forgeqe.yaml): see src/cases/defects.ts, repro.ts, rca.ts. */
373
+ defects: DefectsConfig
348
374
  export: { outDir: string; format: string }
349
375
  memory: MemoryConfig
350
376
  code: CodeConfig
@@ -517,9 +543,16 @@ function browserRemoteConfig(raw: Record<string, unknown>, provider: BrowserProv
517
543
  if (!/chrome|edge|chromium/i.test(browser) || /^(playwright|pw)-/i.test(browser)) {
518
544
  throw new Error(`${where}.remote.browser is "${browser}": ForgeQE drives remote browsers over the Chrome DevTools Protocol, so use chrome or edge.`)
519
545
  }
546
+ // Checked live: TestMu AI's CDP endpoint opens Chrome sessions only; an Edge session never opens and times out.
547
+ if (provider === "testmu" && !/chrome/i.test(browser)) {
548
+ throw new Error(`${where}.remote.browser is "${browser}": TestMu AI's CDP endpoint only serves Chrome, so use Chrome there (Edge works on BrowserStack).`)
549
+ }
520
550
  const browserVersion = String(remote.browser_version || remote.browserVersion || "latest").trim() || "latest"
521
- const os = String(remote.os || "").trim() || undefined
522
- const osVersion = String(remote.os_version || remote.osVersion || "").trim() || undefined
551
+ const rawOs = String(remote.os || "").trim() || undefined
552
+ const rawOsVersion = String(remote.os_version || remote.osVersion || "").trim() || undefined
553
+ // BrowserStack documents "Windows" and "OS X" with versions like 11 or Sequoia: what people write maps onto those.
554
+ const os = provider === "browserstack" && rawOs ? browserstackOs(rawOs) : rawOs
555
+ const osVersion = provider === "browserstack" && rawOsVersion && os === "OS X" ? titleCase(rawOsVersion) : rawOsVersion
523
556
  const platform = String(remote.platform || "").trim() || undefined
524
557
  if (provider === "browserstack" && (!os || !osVersion)) {
525
558
  throw new Error(`${where}.remote.os and ${where}.remote.os_version are required when browser.provider is browserstack.`)
@@ -550,28 +583,68 @@ function browserRemoteConfig(raw: Record<string, unknown>, provider: BrowserProv
550
583
  }
551
584
  }
552
585
 
553
- function browserRemoteConfigs(browser: Record<string, unknown>, provider: BrowserProvider, base: string): { remotes: BrowserRemoteConfigs; providerFiles: BrowserProviderFiles } {
586
+ function browserstackOs(os: string): string {
587
+ if (/^(os ?x|macos|mac( ?os)?)$/i.test(os)) return "OS X"
588
+ if (/^win(dows)?$/i.test(os)) return "Windows"
589
+ return os
590
+ }
591
+
592
+ const titleCase = (s: string) => s.toLowerCase().replace(/\b[a-z]/g, (c) => c.toUpperCase())
593
+
594
+ /** A device name: what the user and the agent call it ("win11-chrome"), also used in folder names. */
595
+ export const DEVICE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
596
+
597
+ function browserRemoteConfigs(
598
+ browser: Record<string, unknown>,
599
+ provider: BrowserProvider,
600
+ base: string,
601
+ ): { remotes: BrowserRemoteConfigs; providerFiles: BrowserProviderFiles; devices: Record<string, BrowserDevice> } {
554
602
  const providerFiles = browserProviderFiles(browser, base)
555
603
  const remotes: BrowserRemoteConfigs = {}
604
+ const devices: Record<string, BrowserDevice> = {}
556
605
  for (const remoteProvider of REMOTE_BROWSER_PROVIDERS) {
557
606
  const fromFile = providerFiles[remoteProvider] ? browserProviderFileSection(providerFiles[remoteProvider]!, remoteProvider) : undefined
558
607
  const inline = provider === remoteProvider ? browser : undefined
559
608
  if (!fromFile && !inline) continue
560
609
  const where = fromFile ? `browser.providers.${remoteProvider}` : "browser"
561
- remotes[remoteProvider] = browserRemoteConfig(
562
- {
563
- ...(fromFile || {}),
564
- ...(inline || {}),
565
- remote: { ...asRecord(fromFile?.remote), ...asRecord(inline?.remote) },
566
- },
567
- remoteProvider,
568
- where,
569
- )
610
+ const shared = { ...asRecord(fromFile?.remote), ...asRecord(inline?.remote) }
611
+ const named = objectMap(fromFile?.devices, `${where}.devices`) || {}
612
+ for (const [name, raw] of Object.entries(named)) {
613
+ if (!DEVICE_NAME.test(name)) throw new Error(`${where}.devices.${name}: a device name uses letters, digits, ".", "_" and "-".`)
614
+ const other = devices[name]
615
+ if (other) throw new Error(`Device ${name} is in both ${other.provider} and ${remoteProvider}; device names must be unique.`)
616
+ const remote = browserRemoteConfig({ remote: { ...shared, ...asRecord(raw) } }, remoteProvider, `${where}.devices.${name}`)!
617
+ devices[name] = { name, provider: remoteProvider, remote }
618
+ }
619
+ // A provider file that only lists devices: its first device is the provider's default browser.
620
+ const first = Object.values(devices).find((d) => d.provider === remoteProvider)
621
+ remotes[remoteProvider] =
622
+ !String(shared.browser || "").trim() && first
623
+ ? first.remote
624
+ : browserRemoteConfig({ ...(fromFile || {}), ...(inline || {}), remote: shared }, remoteProvider, where)
570
625
  }
571
- return { remotes, providerFiles }
626
+ return { remotes, providerFiles, devices }
572
627
  }
573
628
 
574
- export function browserConfigFor(config: Pick<AppConfig, "browser">, provider = config.browser.provider): Pick<BrowserConfig, "provider" | "remote"> {
629
+ /**
630
+ * The browser a run uses: a named device (which also picks its provider), else the provider's default remote,
631
+ * else the local browser. A device on another provider than the one asked for is an error, not a silent switch.
632
+ */
633
+ export function browserConfigFor(
634
+ config: Pick<AppConfig, "browser">,
635
+ provider?: BrowserProvider,
636
+ device?: string,
637
+ ): Pick<BrowserConfig, "provider" | "remote"> & { device?: string } {
638
+ if (device) {
639
+ const d = config.browser.devices?.[device]
640
+ if (!d) {
641
+ const known = Object.keys(config.browser.devices || {})
642
+ throw new Error(`No browser device named ${device}. ${known.length ? `Configured: ${known.join(", ")}.` : "None is configured yet."} Add one with browser_devices, or under devices: in the provider's YAML file.`)
643
+ }
644
+ if (provider && provider !== d.provider) throw new Error(`Device ${device} is on ${d.provider}, not ${provider}.`)
645
+ // The session name shows the device in the provider's dashboard.
646
+ return { provider: d.provider, remote: { ...d.remote, name: [d.remote.name, device].filter(Boolean).join(" · ") }, device }
647
+ }
575
648
  const chosen = provider || config.browser.provider || "local"
576
649
  if (chosen === "local") return { provider: chosen }
577
650
  const remote = config.browser.remotes?.[chosen] || ((config.browser.provider === chosen || !config.browser.provider) ? config.browser.remote : undefined)
@@ -751,7 +824,7 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
751
824
 
752
825
  const sessionsDir = configPath ? path.join(projectDir(base), "sessions") : path.join(forgeqeHome(), "sessions")
753
826
  const provider = browserProvider(browser.provider)
754
- const { remotes, providerFiles } = browserRemoteConfigs(browser, provider, base)
827
+ const { remotes, providerFiles, devices } = browserRemoteConfigs(browser, provider, base)
755
828
  const config: AppConfig = {
756
829
  configPath,
757
830
  mcp: extraMcpServers(yaml.mcp, base),
@@ -771,6 +844,7 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
771
844
  remote: provider === "local" ? undefined : remotes[provider],
772
845
  remotes,
773
846
  providerFiles,
847
+ devices,
774
848
  overlay: browser.overlay !== false,
775
849
  judge: browser.judge === true,
776
850
  },
@@ -780,6 +854,7 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
780
854
  dirs: promptDirsFor(prompts, base, configPath),
781
855
  },
782
856
  knowledge: knowledgeConfig(asRecord(yaml.knowledge), base, configPath),
857
+ defects: defectsConfig(asRecord(yaml.defects), base),
783
858
  export: {
784
859
  outDir,
785
860
  format: String(exp.format || "playwright"),
@@ -799,6 +874,7 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
799
874
  testTimeoutSeconds: Number(code.test_timeout_seconds || 600),
800
875
  stallAfter: Math.max(2, Number(code.stall_after || 5)),
801
876
  liveBrowser: code.live_browser !== false,
877
+ graph: code.graph !== false,
802
878
  replay: code.replay !== false,
803
879
  model: code.model ? String(code.model) : undefined,
804
880
  ...(code.review_profile ? { reviewProfile: String(code.review_profile) } : {}),
@@ -1001,6 +1077,26 @@ function sourcesConfig(raw: Record<string, unknown>): SourcesConfig {
1001
1077
  ...optional("zephyr", zephyrConfig(raw.zephyr)),
1002
1078
  ...optional("qtest", qtestConfig(asRecord(raw.qtest))),
1003
1079
  ...optional("bugs", bugsConfig(raw.bugs)),
1080
+ ...optional("github", githubConfig(raw.github)),
1081
+ }
1082
+ }
1083
+
1084
+ /** The GitHub MCP server's tool groups ForgeQE loads unless sources.github.toolsets says otherwise. */
1085
+ export const GITHUB_TOOLSETS = ["repos", "issues", "pull_requests", "actions"]
1086
+
1087
+ function githubConfig(raw: unknown): SourcesConfig["github"] {
1088
+ if (raw === undefined || raw === null) return undefined
1089
+ if (raw === false) return { apiUrl: "https://api.github.com", tokenEnv: "GITHUB_TOKEN", mcp: false, toolsets: [] }
1090
+ const g = asRecord(raw)
1091
+ const repo = String(g.repo || "").trim()
1092
+ if (repo && !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new Error(`sources.github.repo is owner/name; got "${repo}".`)
1093
+ const toolsets = Array.isArray(g.toolsets) ? g.toolsets.map(String).filter(Boolean) : GITHUB_TOOLSETS
1094
+ return {
1095
+ ...(repo ? { repo } : {}),
1096
+ apiUrl: String(g.api_url || "https://api.github.com").replace(/\/+$/, ""),
1097
+ tokenEnv: String(g.token_env || "GITHUB_TOKEN"),
1098
+ mcp: g.mcp !== false,
1099
+ toolsets,
1004
1100
  }
1005
1101
  }
1006
1102
 
@@ -1024,6 +1120,27 @@ function promptDirsFor(p: Record<string, unknown>, base: string, configPath: str
1024
1120
  return dirs.filter((d, i) => dirs.findIndex((x) => path.resolve(x.dir) === path.resolve(d.dir)) === i)
1025
1121
  }
1026
1122
 
1123
+ export type DefectsConfig = {
1124
+ /** The application's own repository: suspect commits, and the code the root-cause analysis reads. */
1125
+ productRepo?: string
1126
+ /** Run the first case of each new defect once more, to see whether it repeats. */
1127
+ reproduce: boolean
1128
+ /** A model writes a root-cause hypothesis for new defects and regressions. */
1129
+ analyze: boolean
1130
+ /** Most defects analysed by the model in one suite. */
1131
+ maxAnalyses: number
1132
+ }
1133
+
1134
+ export function defectsConfig(d: Record<string, unknown>, base: string): DefectsConfig {
1135
+ const max = Number(d.max_analyses)
1136
+ return {
1137
+ ...(d.product_repo ? { productRepo: path.resolve(base, String(d.product_repo)) } : {}),
1138
+ reproduce: d.reproduce !== false,
1139
+ analyze: d.analyze !== false,
1140
+ maxAnalyses: Number.isInteger(max) && max >= 0 && d.max_analyses !== undefined ? max : 3,
1141
+ }
1142
+ }
1143
+
1027
1144
  function knowledgeConfig(k: Record<string, unknown>, base: string, configPath: string | undefined): NonNullable<AppConfig["knowledge"]> {
1028
1145
  const dir = k.dir ? path.resolve(base, String(k.dir)) : configPath ? path.join(projectDir(base), "knowledge") : path.join(forgeqeHome(), "knowledge")
1029
1146
  const max = Number(k.browser_max_chars)
@@ -1163,7 +1280,7 @@ function bugsConfig(raw: unknown): BugsConfig | undefined {
1163
1280
  if (raw === undefined || raw === null || raw === false) return undefined
1164
1281
  const b = asRecord(raw)
1165
1282
  const system = String(b.system || "").toLowerCase().replace(/[\s-]/g, "_")
1166
- if (system !== "jira" && system !== "azure_devops") throw new Error(`sources.bugs.system must be jira or azure_devops; got "${String(b.system ?? "")}".`)
1283
+ if (system !== "jira" && system !== "azure_devops" && system !== "github") throw new Error(`sources.bugs.system must be jira, azure_devops, or github; got "${String(b.system ?? "")}".`)
1167
1284
  if (system === "jira" && !b.project) throw new Error("sources.bugs.project is required for Jira: the project key bugs are filed in.")
1168
1285
  return {
1169
1286
  system,
@@ -1172,9 +1289,16 @@ function bugsConfig(raw: unknown): BugsConfig | undefined {
1172
1289
  labels: Array.isArray(b.labels) ? b.labels.map(String).filter(Boolean) : ["forgeqe"],
1173
1290
  ...(b.area_path ? { areaPath: String(b.area_path) } : {}),
1174
1291
  onError: b.on_error === true,
1292
+ ...(groupBy(b.group_by) === "case" ? { groupBy: "case" as const } : {}),
1175
1293
  }
1176
1294
  }
1177
1295
 
1296
+ function groupBy(raw: unknown): "defect" | "case" {
1297
+ if (raw === undefined || raw === null || raw === "defect") return "defect"
1298
+ if (raw === "case") return "case"
1299
+ throw new Error(`sources.bugs.group_by must be defect or case; got "${String(raw)}".`)
1300
+ }
1301
+
1178
1302
  function jiraMcp(raw: unknown): JiraMcp | false {
1179
1303
  if (raw === false || raw === "false" || raw === "off") return false
1180
1304
  if (raw === undefined || raw === null || raw === true || raw === "mcp-atlassian") return "mcp-atlassian"
@@ -0,0 +1,223 @@
1
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import { adoSettings } from "../cases/ado.ts"
5
+ import { http, textToAdf } from "../cases/http.ts"
6
+ import { jiraApi } from "../cases/jira.ts"
7
+ import type { AppConfig } from "../config.ts"
8
+ import type { McpTool } from "./tools.ts"
9
+
10
+ /**
11
+ * Evidence onto an existing work item or issue: files from a run (screenshots, the report, JUnit, the trace) uploaded
12
+ * as attachments, posted in a comment (images inline, text as text), or both. Only files inside the working folder or
13
+ * the reports folders can go, so a request can never send anything else from the machine.
14
+ */
15
+
16
+ const MAX_FILE = 25 * 1024 * 1024
17
+ /** Text read into a comment; longer text is attached instead. */
18
+ const MAX_COMMENT_TEXT = 20_000
19
+ const IMAGE = /\.(png|jpe?g|gif|webp)$/i
20
+ const TEXT = /\.(md|txt|log|json|xml|csv|ya?ml)$/i
21
+ const MIME: Record<string, string> = {
22
+ ".png": "image/png",
23
+ ".jpg": "image/jpeg",
24
+ ".jpeg": "image/jpeg",
25
+ ".gif": "image/gif",
26
+ ".webp": "image/webp",
27
+ ".html": "text/html",
28
+ ".json": "application/json",
29
+ ".xml": "application/xml",
30
+ }
31
+
32
+ export type Pick = "last_screenshot" | "failure_screenshot" | "report" | "junit" | "trace"
33
+ const PICKS: Pick[] = ["last_screenshot", "failure_screenshot", "report", "junit", "trace"]
34
+
35
+ type File = { path: string; name: string; data: Buffer; type: string }
36
+
37
+ /** Files a run folder holds, by what they are. A suite folder's last screenshot is its last case's. */
38
+ export function pickFiles(runDir: string, picks: Pick[]): string[] {
39
+ const out: string[] = []
40
+ const lastShot = (dir: string): string | undefined => {
41
+ const steps = path.join(dir, "steps")
42
+ const shots = existsSync(steps) ? readdirSync(steps).filter((f) => IMAGE.test(f)).sort() : []
43
+ if (shots.length) return path.join(steps, shots[shots.length - 1])
44
+ if (existsSync(path.join(dir, "failure.png"))) return path.join(dir, "failure.png")
45
+ // A suite folder: its case folders, last one first.
46
+ const cases = readdirSync(dir).filter((d) => statSync(path.join(dir, d)).isDirectory() && d !== "steps").sort().reverse()
47
+ for (const c of cases) {
48
+ const shot = lastShot(path.join(dir, c))
49
+ if (shot) return shot
50
+ }
51
+ return undefined
52
+ }
53
+ for (const pick of picks) {
54
+ const file =
55
+ pick === "last_screenshot"
56
+ ? lastShot(runDir)
57
+ : pick === "failure_screenshot"
58
+ ? [path.join(runDir, "failure.png")].find(existsSync)
59
+ : pick === "report"
60
+ ? path.join(runDir, "report.html")
61
+ : pick === "junit"
62
+ ? path.join(runDir, "junit.xml")
63
+ : path.join(runDir, "trace.md")
64
+ if (!file || !existsSync(file)) throw new Error(`${runDir} has no ${pick.replace("_", " ")}.`)
65
+ out.push(file)
66
+ }
67
+ return out
68
+ }
69
+
70
+ /** A file to send: inside the working folder or a reports folder, and not too big. */
71
+ function readAllowed(file: string, config: AppConfig, cwd: string): File {
72
+ if (!existsSync(file)) throw new Error(`No such file: ${file}`)
73
+ const real = realpathSync(file)
74
+ const roots = [cwd, config.export?.outDir, config.suite?.outDir, config.monitor?.outDir].filter(Boolean).map((r) => (existsSync(r!) ? realpathSync(r!) : path.resolve(r!)))
75
+ if (!roots.some((r) => real === r || real.startsWith(`${r}${path.sep}`))) {
76
+ throw new Error(`${file} is outside the working folder and the reports folders, so it is not sent.`)
77
+ }
78
+ const size = statSync(real).size
79
+ if (size > MAX_FILE) throw new Error(`${file} is ${(size / 1024 / 1024).toFixed(1)} MB; the limit is ${MAX_FILE / 1024 / 1024} MB.`)
80
+ return { path: real, name: path.basename(real), data: readFileSync(real), type: MIME[path.extname(real).toLowerCase()] || "application/octet-stream" }
81
+ }
82
+
83
+ const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
84
+
85
+ type Outcome = { item: string; url?: string; attached: string[]; commented: boolean; inline: string[] }
86
+
87
+ async function toAdo(config: AppConfig, id: string, files: File[], mode: string, comment: string): Promise<Outcome> {
88
+ const { auth, org } = adoSettings(config.sources)
89
+ if (!/^\d+$/.test(id)) throw new Error(`An Azure DevOps work item id is a number; got "${id}".`)
90
+ // Comments and attachments live in the work item's own project.
91
+ const { data: wi } = await http<{ fields?: Record<string, unknown>; _links?: { html?: { href?: string } } }>(`${org}/_apis/wit/workitems/${id}?fields=System.TeamProject&api-version=7.1`, { auth })
92
+ const project = encodeURIComponent(String(wi.fields?.["System.TeamProject"] || ""))
93
+ const scoped = `${org}/${project}`
94
+ const upload = async (f: File) => {
95
+ const res = await fetch(`${scoped}/_apis/wit/attachments?fileName=${encodeURIComponent(f.name)}&api-version=7.1`, {
96
+ method: "POST",
97
+ headers: { Authorization: auth, "Content-Type": "application/octet-stream", Accept: "application/json" },
98
+ body: new Uint8Array(f.data),
99
+ })
100
+ if (!res.ok) throw new Error(`Uploading ${f.name} to work item ${id} failed: ${res.status} ${res.statusText}.`)
101
+ return ((await res.json()) as { url: string }).url
102
+ }
103
+ const out: Outcome = { item: `#${id}`, url: wi._links?.html?.href, attached: [], commented: false, inline: [] }
104
+ const urls = new Map<string, string>()
105
+ if (mode === "attach" || mode === "both") {
106
+ const ops = []
107
+ for (const f of files) {
108
+ const url = await upload(f)
109
+ urls.set(f.path, url)
110
+ ops.push({ op: "add", path: "/relations/-", value: { rel: "AttachedFile", url, attributes: { comment: comment ? comment.slice(0, 250) : "Added by ForgeQE" } } })
111
+ out.attached.push(f.name)
112
+ }
113
+ await http(`${org}/_apis/wit/workitems/${id}?api-version=7.1`, { method: "PATCH", auth, body: ops, contentType: "application/json-patch+json" })
114
+ }
115
+ if (mode === "comment" || mode === "both") {
116
+ const parts = comment ? [`<p>${esc(comment).replace(/\n/g, "<br>")}</p>`] : []
117
+ for (const f of files) {
118
+ if (IMAGE.test(f.name)) {
119
+ const url = urls.get(f.path) || (await upload(f))
120
+ parts.push(`<p><b>${esc(f.name)}</b><br><img src="${url}" alt="${esc(f.name)}"></p>`)
121
+ out.inline.push(f.name)
122
+ } else if (TEXT.test(f.name) && f.data.length <= MAX_COMMENT_TEXT) {
123
+ parts.push(`<p><b>${esc(f.name)}</b></p><pre>${esc(f.data.toString("utf8"))}</pre>`)
124
+ out.inline.push(f.name)
125
+ } else {
126
+ // An HTML report or a big file cannot go in a comment: it is attached, and the comment names it.
127
+ const url = urls.get(f.path) || (await upload(f))
128
+ if (!urls.has(f.path)) {
129
+ await http(`${org}/_apis/wit/workitems/${id}?api-version=7.1`, {
130
+ method: "PATCH",
131
+ auth,
132
+ body: [{ op: "add", path: "/relations/-", value: { rel: "AttachedFile", url, attributes: { comment: "Added by ForgeQE" } } }],
133
+ contentType: "application/json-patch+json",
134
+ })
135
+ out.attached.push(f.name)
136
+ }
137
+ parts.push(`<p>Attached: <a href="${url}">${esc(f.name)}</a></p>`)
138
+ }
139
+ }
140
+ await http(`${scoped}/_apis/wit/workItems/${id}/comments?api-version=7.1-preview.4`, { auth, body: { text: parts.join("\n") } })
141
+ out.commented = true
142
+ }
143
+ return out
144
+ }
145
+
146
+ async function toJira(config: AppConfig, key: string, files: File[], mode: string, comment: string): Promise<Outcome> {
147
+ const j = jiraApi(config.sources, "Attaching to Jira")
148
+ const issue = key.trim().toUpperCase()
149
+ if (!/^[A-Z][A-Z0-9_]*-\d+$/.test(issue)) throw new Error(`A Jira issue key looks like SHOP-12; got "${key}".`)
150
+ const out: Outcome = { item: issue, url: `${j.url}/browse/${issue}`, attached: [], commented: false, inline: [] }
151
+ // Jira shows images from the issue's attachments, so files go up as attachments in either mode.
152
+ const send = files.filter((f) => mode !== "comment" || !(TEXT.test(f.name) && f.data.length <= MAX_COMMENT_TEXT))
153
+ for (const f of send) {
154
+ const form = new FormData()
155
+ form.append("file", new Blob([new Uint8Array(f.data)], { type: f.type }), f.name)
156
+ const res = await fetch(`${j.api}/issue/${encodeURIComponent(issue)}/attachments`, {
157
+ method: "POST",
158
+ headers: { Authorization: j.auth, "X-Atlassian-Token": "no-check", Accept: "application/json" },
159
+ body: form,
160
+ })
161
+ if (!res.ok) throw new Error(`Attaching ${f.name} to ${issue} failed: ${res.status} ${res.statusText}.`)
162
+ out.attached.push(f.name)
163
+ }
164
+ if (mode === "comment" || mode === "both") {
165
+ const lines = comment ? [comment, ""] : []
166
+ for (const f of files) {
167
+ if (TEXT.test(f.name) && f.data.length <= MAX_COMMENT_TEXT) {
168
+ lines.push(`${f.name}:`, f.data.toString("utf8"), "")
169
+ out.inline.push(f.name)
170
+ } else if (IMAGE.test(f.name) && !j.cloud) {
171
+ // Jira Server/Data Center wiki markup shows an attached image in the comment.
172
+ lines.push(`!${f.name}|thumbnail!`)
173
+ out.inline.push(f.name)
174
+ } else lines.push(`Attached: ${f.name}`)
175
+ }
176
+ const text = lines.join("\n").trim()
177
+ await http(`${j.api}/issue/${encodeURIComponent(issue)}/comment`, { auth: j.auth, body: { body: j.cloud ? textToAdf(text) : text } })
178
+ out.commented = true
179
+ }
180
+ return out
181
+ }
182
+
183
+ export const ATTACH_TOOLS: McpTool[] = [
184
+ {
185
+ name: "attach_to_work_item",
186
+ description:
187
+ "Put evidence on an existing Azure DevOps work item or Jira issue, when the user asks (\"add the screenshot to ADO 256\", \"attach the report to SHOP-12\", \"comment the results on the bug\"). mode attach uploads the files as attachments; comment posts a comment with images shown inline and text files as text (an HTML report cannot be shown in a comment, so it is attached and named); both does both. Files come from a run folder by what they are (pick), or by path; only files in the working folder or the reports folders can be sent. Returns the item's link and what went where.",
188
+ inputSchema: {
189
+ type: "object",
190
+ properties: {
191
+ system: { type: "string", enum: ["azure_devops", "jira"] },
192
+ item: { type: "string", description: "Work item id (azure_devops), e.g. 256, or issue key (jira), e.g. SHOP-12" },
193
+ mode: { type: "string", enum: ["attach", "comment", "both"], description: "attach: upload as attachments. comment: one comment with the files in it. both: attachments and a comment (default when the user did not say)" },
194
+ run_dir: { type: "string", description: "A run or suite folder from browser_run or run_test_cases, to pick files from" },
195
+ pick: { type: "array", items: { type: "string", enum: PICKS }, description: "What to take from run_dir: last_screenshot, failure_screenshot, report, junit, trace" },
196
+ files: { type: "array", items: { type: "string" }, description: "Other files by path, relative to the working folder" },
197
+ comment: { type: "string", description: "Text for the comment (comment/both), and the attachment note: what ran, where, the result" },
198
+ },
199
+ required: ["system", "item"],
200
+ },
201
+ async run(args, ctx) {
202
+ const mode = String(args.mode || "both")
203
+ if (!["attach", "comment", "both"].includes(mode)) throw new Error(`mode is attach, comment, or both; got "${mode}".`)
204
+ const picks = (Array.isArray(args.pick) ? args.pick.map(String) : []) as Pick[]
205
+ const bad = picks.filter((p) => !PICKS.includes(p))
206
+ if (bad.length) throw new Error(`pick takes ${PICKS.join(", ")}; got ${bad.join(", ")}.`)
207
+ if (picks.length && !args.run_dir) throw new Error("pick needs run_dir: the run folder to take the files from.")
208
+ const runDir = args.run_dir ? path.resolve(ctx.cwd, String(args.run_dir)) : ""
209
+ const paths = [...(runDir ? pickFiles(runDir, picks.length ? picks : ["last_screenshot"]) : []), ...(Array.isArray(args.files) ? args.files.map((f) => path.resolve(ctx.cwd, String(f))) : [])]
210
+ const comment = String(args.comment || "").trim()
211
+ if (!paths.length && !comment) throw new Error("Nothing to add: give run_dir (and pick), files, or a comment.")
212
+ if (!paths.length && mode === "attach") throw new Error("Nothing to attach: give run_dir (and pick) or files.")
213
+ const files = [...new Set(paths)].map((p) => readAllowed(p, ctx.config, ctx.cwd))
214
+ const out = args.system === "jira" ? await toJira(ctx.config, String(args.item), files, mode, comment) : await toAdo(ctx.config, String(args.item), files, mode, comment)
215
+ return {
216
+ item: out.item,
217
+ ...(out.url ? { link: out.url } : {}),
218
+ ...(out.attached.length ? { attached: out.attached } : {}),
219
+ ...(out.commented ? { comment: out.inline.length ? `posted, with ${out.inline.join(", ")} in it` : "posted" } : {}),
220
+ }
221
+ },
222
+ },
223
+ ]
@@ -361,7 +361,7 @@ export const COMMAND_TOOLS: McpTool[] = [
361
361
  note: "The user switches profiles with Tab or /profile in the shell, or --profile.",
362
362
  }
363
363
  case "mcp_servers": {
364
- const { servers, skipped } = externalMcp(cfg)
364
+ const { servers, skipped } = externalMcp(cfg, Bun.which, process.env, { cwd: ctx.cwd })
365
365
  return {
366
366
  servers: servers.map((s) => {
367
367
  const c = s.config as { type: string; command?: string[]; url?: string }
@@ -0,0 +1,106 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { mkdirSync, writeFileSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ import { areaFacts, changeOf, defectTrends, predict, predictionLines, savePrediction, trendsMarkdown, type Prediction } from "../cases/predict.ts"
6
+ import type { McpTool } from "./tools.ts"
7
+
8
+ /** The branch a change is compared with when none is named: the remote's default branch, else main, else master. */
9
+ export function defaultBase(repo: string): string {
10
+ const ok = (ref: string) => spawnSync("git", ["-C", repo, "rev-parse", "--verify", "--quiet", ref], { encoding: "utf8" }).status === 0
11
+ const head = spawnSync("git", ["-C", repo, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], { encoding: "utf8" })
12
+ if (head.status === 0 && head.stdout.trim()) return head.stdout.trim()
13
+ for (const ref of ["origin/main", "main", "origin/master", "master"]) if (ok(ref)) return ref
14
+ throw new Error("No base branch found (origin/HEAD, main, or master): name one with base.")
15
+ }
16
+
17
+ const pct = (a: number, b: number) => (b ? `${Math.round((a / b) * 100)}%` : "—")
18
+
19
+ export const DEFECT_TOOLS: McpTool[] = [
20
+ {
21
+ name: "defect_trends",
22
+ description:
23
+ "How the defects of the saved suite runs went over time, from defect-history.json next to the suite folders: how many are still there, gone, or came back after a fix, the time they took to go away, the most seen ones with their bugs, defects by area (page or API route), defects per suite, and how often defect predictions were right. Writes defect-trends.md next to the suites. Shows a card. Nothing runs.",
24
+ inputSchema: {
25
+ type: "object",
26
+ properties: {
27
+ folder: { type: "string", description: "Folder of saved suites (default: suite.out_dir in forgeqe.yaml)" },
28
+ },
29
+ },
30
+ async run(args, ctx) {
31
+ const folder = args.folder ? path.resolve(ctx.cwd, String(args.folder)) : ctx.config.suite.outDir
32
+ const t = defectTrends(folder)
33
+ const md = trendsMarkdown(t)
34
+ mkdirSync(folder, { recursive: true })
35
+ const file = path.join(folder, "defect-trends.md")
36
+ writeFileSync(file, md)
37
+ const p = t.predictions
38
+ ctx.emit?.({
39
+ type: "card",
40
+ card: {
41
+ status: t.defects ? (t.open ? "fail" : "ok") : "info",
42
+ title: t.defects ? `${t.defects} defect${t.defects === 1 ? "" : "s"}: ${t.open} still there, ${t.gone} gone, ${t.regressions} came back` : "No defect history yet",
43
+ stats: `${t.suites} suite run${t.suites === 1 ? "" : "s"}`,
44
+ body: t.defects
45
+ ? [
46
+ ...(t.daysToFix ? [`Time to go away: median ${t.daysToFix.median} days over ${t.daysToFix.count}`] : []),
47
+ ...(p ? [`Predictions (${p.scored} scored): ${p.inPredicted} of ${p.defects} defects (${pct(p.inPredicted, p.defects)}) were in a predicted area; ${p.highWithDefect} of ${p.high} high-risk areas (${pct(p.highWithDefect, p.high)}) had one`] : []),
48
+ "",
49
+ "Most seen:",
50
+ ...t.recurring.slice(0, 5).map((d) => ` ${d.seen}× ${d.title}${d.open ? "" : " (gone)"}${d.returns ? `, came back ${d.returns}×` : ""}${d.bug ? ` ${d.bug.label}` : ""}`),
51
+ ...(t.areas.length ? ["", "By area:", ...t.areas.slice(0, 5).map((a) => ` ${a.area}: ${a.defects} defect${a.defects === 1 ? "" : "s"}${a.runs ? `, ${a.failed} of ${a.runs} case runs failed` : ""}`)] : []),
52
+ "",
53
+ `Report: ${path.relative(ctx.cwd, file) || file}`,
54
+ ]
55
+ : ["Run suites into this folder first: each one adds its defects to defect-history.json."],
56
+ },
57
+ })
58
+ return { ...t, report: file }
59
+ },
60
+ },
61
+ {
62
+ name: "predict_defects",
63
+ description:
64
+ "Where a change is likely to cause defects. Compares a git range of the product's repository (base...head; default: the default branch to HEAD, in defects.product_repo or the working directory) with what the saved suites know about each area (page or API route): the change touches an area when it changes a file named after it or its diff mentions the route; the area's earlier defects, defects that came back, defects still open, and case failures raise its risk. Gives each area high, medium, or low with every reason, the cases to run first, and changed code no case reaches. Saves the prediction; the next suite scores it, and defect_trends says how often predictions were right. A heuristic, not a model. Shows a card. Nothing runs.",
65
+ inputSchema: {
66
+ type: "object",
67
+ properties: {
68
+ repo: { type: "string", description: "The product's repository (default: defects.product_repo, else the working directory)" },
69
+ base: { type: "string", description: "Base ref of the change (default: origin/HEAD, main, or master)" },
70
+ head: { type: "string", description: "Head ref of the change (default: HEAD)" },
71
+ folder: { type: "string", description: "Folder of saved suites (default: suite.out_dir in forgeqe.yaml)" },
72
+ },
73
+ },
74
+ async run(args, ctx) {
75
+ const repo = args.repo ? path.resolve(ctx.cwd, String(args.repo)) : ctx.config.defects?.productRepo || ctx.cwd
76
+ const folder = args.folder ? path.resolve(ctx.cwd, String(args.folder)) : ctx.config.suite.outDir
77
+ const base = args.base ? String(args.base) : defaultBase(repo)
78
+ const head = args.head ? String(args.head) : "HEAD"
79
+ const change = changeOf(repo, base, head)
80
+ const facts = areaFacts(folder)
81
+ const at = new Date().toISOString()
82
+ const p: Prediction = { id: at.replace(/[:.]/g, "-"), at, repo, base, head, changed: change.files, ...predict(facts, change) }
83
+ mkdirSync(folder, { recursive: true })
84
+ savePrediction(folder, p)
85
+ const high = p.areas.filter((a) => a.level === "high").length
86
+ ctx.emit?.({
87
+ type: "card",
88
+ card: {
89
+ status: high ? "fail" : p.areas.length ? "info" : "ok",
90
+ title: p.areas.length ? `${high} high-risk area${high === 1 ? "" : "s"} in ${base}...${head}` : `No known area touched by ${base}...${head}`,
91
+ stats: `${p.changed.length} changed file${p.changed.length === 1 ? "" : "s"} · ${facts.size} known area${facts.size === 1 ? "" : "s"}`,
92
+ body: [...predictionLines(p), ...(facts.size ? [] : ["", "ForgeQE knows no areas yet: run suites into this folder first."])],
93
+ },
94
+ })
95
+ return {
96
+ base,
97
+ head,
98
+ changed: p.changed,
99
+ areas: p.areas,
100
+ run_first: p.runFirst,
101
+ untested: p.untested,
102
+ next_step: p.runFirst.length ? "Offer to run the run_first cases before the others (run_test_cases)." : undefined,
103
+ }
104
+ },
105
+ },
106
+ ]