@capagents/forgeqe 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/HOW_TO_USE.md +1639 -0
  2. package/README.md +338 -2
  3. package/bin/forgeqe.d.ts +3 -0
  4. package/bin/forgeqe.js +188 -0
  5. package/docs/ARCHITECTURE.md +408 -0
  6. package/docs/security-data-flow.html +1870 -0
  7. package/docs/user-guide.html +2094 -0
  8. package/evals/domain/access-role-change.yaml +37 -0
  9. package/evals/domain/banking-payment-transfer.yaml +41 -0
  10. package/evals/domain/gdpr-account-erasure.yaml +30 -0
  11. package/evals/domain/insurance-claim-fnol.yaml +30 -0
  12. package/evals/domain/insurance-policy-renewal.yaml +37 -0
  13. package/evals/domain/lending-loan-application.yaml +36 -0
  14. package/forgeqe-architecture.html +718 -0
  15. package/forgeqe.example.yaml +203 -0
  16. package/knowledge/README.md +18 -0
  17. package/knowledge/business-rules.md +10 -0
  18. package/knowledge/compliance.md +16 -0
  19. package/knowledge/environments.md +5 -0
  20. package/knowledge/glossary.md +5 -0
  21. package/knowledge/product.md +16 -0
  22. package/knowledge/roles.md +9 -0
  23. package/knowledge/test-data.md +7 -0
  24. package/llms.example.json +51 -0
  25. package/package.json +63 -4
  26. package/prompts/README.md +34 -0
  27. package/prompts/api-scenarios.md +21 -0
  28. package/prompts/api-tests.md +39 -0
  29. package/prompts/brain.md +166 -0
  30. package/prompts/browser-capture.md +7 -0
  31. package/prompts/browser-case-report.md +7 -0
  32. package/prompts/browser-checks.md +14 -0
  33. package/prompts/browser-efficiency.md +7 -0
  34. package/prompts/browser-error-recovery.md +15 -0
  35. package/prompts/browser-extra.md +5 -0
  36. package/prompts/browser-goal-verification.md +13 -0
  37. package/prompts/browser-skill-select.md +6 -0
  38. package/prompts/domain-rule.md +13 -0
  39. package/prompts/eval-judge.md +19 -0
  40. package/prompts/explore-charters.md +27 -0
  41. package/prompts/find-tests.md +28 -0
  42. package/prompts/heal.md +40 -0
  43. package/prompts/pr-review.md +29 -0
  44. package/prompts/skill-rule.md +6 -0
  45. package/prompts/test-agent.md +34 -0
  46. package/prompts/test-reviewer.md +30 -0
  47. package/prompts/test-rules.md +28 -0
  48. package/py/forgeqe_engine/__init__.py +3 -0
  49. package/py/forgeqe_engine/__main__.py +3 -0
  50. package/py/forgeqe_engine/brand.py +453 -0
  51. package/py/forgeqe_engine/brand_assets.py +6 -0
  52. package/py/forgeqe_engine/browser.py +196 -0
  53. package/py/forgeqe_engine/capture.py +538 -0
  54. package/py/forgeqe_engine/checks.py +98 -0
  55. package/py/forgeqe_engine/cli.py +188 -0
  56. package/py/forgeqe_engine/diagnostics.py +609 -0
  57. package/py/forgeqe_engine/llm.py +195 -0
  58. package/py/forgeqe_engine/logins.py +251 -0
  59. package/py/forgeqe_engine/loops.py +224 -0
  60. package/py/forgeqe_engine/memory.py +1004 -0
  61. package/py/forgeqe_engine/network.py +244 -0
  62. package/py/forgeqe_engine/prompts.py +20 -0
  63. package/py/forgeqe_engine/replay.py +240 -0
  64. package/py/forgeqe_engine/report.py +173 -0
  65. package/py/forgeqe_engine/service.py +1335 -0
  66. package/py/forgeqe_engine/settings.py +93 -0
  67. package/py/forgeqe_engine/skills.py +88 -0
  68. package/py/forgeqe_engine/summary.py +239 -0
  69. package/py/forgeqe_engine/urls.py +81 -0
  70. package/py/forgeqe_engine/workflow_yaml.py +689 -0
  71. package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
  72. package/skills/dynamics-365/SKILL.md +21 -0
  73. package/skills/playwright-typescript/SKILL.md +35 -0
  74. package/skills/writing-skills/SKILL.md +30 -0
  75. package/src/api/plan.ts +246 -0
  76. package/src/api/run.ts +204 -0
  77. package/src/api/scenario.ts +502 -0
  78. package/src/api/schema.ts +170 -0
  79. package/src/api/spec.ts +624 -0
  80. package/src/api/store.ts +232 -0
  81. package/src/api/traffic.ts +281 -0
  82. package/src/api/types.ts +163 -0
  83. package/src/api/write.ts +272 -0
  84. package/src/auth/signin.ts +494 -0
  85. package/src/auth/store.ts +170 -0
  86. package/src/brain/index.ts +189 -0
  87. package/src/brain/prompt.ts +19 -0
  88. package/src/capabilities/docs.ts +101 -0
  89. package/src/capabilities/guide.ts +455 -0
  90. package/src/capabilities/registry.ts +343 -0
  91. package/src/capabilities/render.ts +106 -0
  92. package/src/capabilities/status.ts +137 -0
  93. package/src/card.ts +11 -0
  94. package/src/cases/ado.ts +325 -0
  95. package/src/cases/api.ts +117 -0
  96. package/src/cases/bugs.ts +223 -0
  97. package/src/cases/confluence.ts +111 -0
  98. package/src/cases/context.ts +223 -0
  99. package/src/cases/dashboards.ts +137 -0
  100. package/src/cases/failure.ts +97 -0
  101. package/src/cases/files.ts +295 -0
  102. package/src/cases/goal.ts +53 -0
  103. package/src/cases/http.ts +111 -0
  104. package/src/cases/index.ts +131 -0
  105. package/src/cases/jira.ts +122 -0
  106. package/src/cases/qtest.ts +108 -0
  107. package/src/cases/quality.ts +190 -0
  108. package/src/cases/rag.ts +100 -0
  109. package/src/cases/readiness.ts +284 -0
  110. package/src/cases/save.ts +149 -0
  111. package/src/cases/sprints.ts +148 -0
  112. package/src/cases/testrail.ts +176 -0
  113. package/src/cases/types.ts +107 -0
  114. package/src/cases/wiki.ts +105 -0
  115. package/src/cases/xray.ts +206 -0
  116. package/src/cases/zephyr.ts +153 -0
  117. package/src/childEnv.ts +86 -0
  118. package/src/cli/args.ts +114 -0
  119. package/src/cli/help.ts +167 -0
  120. package/src/cli/index.ts +1142 -0
  121. package/src/cli/terminal.ts +187 -0
  122. package/src/code/areas.ts +337 -0
  123. package/src/code/attention.ts +214 -0
  124. package/src/code/find.ts +540 -0
  125. package/src/code/handoff.ts +541 -0
  126. package/src/code/history.ts +170 -0
  127. package/src/code/impact.ts +231 -0
  128. package/src/code/index.ts +34 -0
  129. package/src/code/livebrowser.ts +67 -0
  130. package/src/code/loop.ts +603 -0
  131. package/src/code/notes.ts +75 -0
  132. package/src/code/profile.ts +226 -0
  133. package/src/code/prompt.ts +303 -0
  134. package/src/code/prreview.ts +649 -0
  135. package/src/code/prtests.ts +103 -0
  136. package/src/code/replay.ts +772 -0
  137. package/src/code/review.ts +202 -0
  138. package/src/code/runtime.ts +781 -0
  139. package/src/code/slices.ts +159 -0
  140. package/src/code/testrun.ts +187 -0
  141. package/src/code/traceability.ts +100 -0
  142. package/src/code/worktree.ts +153 -0
  143. package/src/commands.ts +101 -0
  144. package/src/config.ts +970 -0
  145. package/src/detail.ts +65 -0
  146. package/src/domain.ts +19 -0
  147. package/src/engine/bridge.ts +359 -0
  148. package/src/engine/mock.ts +102 -0
  149. package/src/engine/setup.ts +298 -0
  150. package/src/entra.ts +154 -0
  151. package/src/env.ts +24 -0
  152. package/src/evals/domain.ts +338 -0
  153. package/src/explore/charters.ts +151 -0
  154. package/src/explore/run.ts +280 -0
  155. package/src/explore/story.ts +106 -0
  156. package/src/guard.ts +51 -0
  157. package/src/heal/loop.ts +357 -0
  158. package/src/heal/prompt.ts +143 -0
  159. package/src/heal/report.ts +95 -0
  160. package/src/index.ts +12 -0
  161. package/src/init.ts +102 -0
  162. package/src/knowledge.ts +114 -0
  163. package/src/legacyEnv.ts +9 -0
  164. package/src/llm.ts +61 -0
  165. package/src/locale.ts +25 -0
  166. package/src/logins.ts +148 -0
  167. package/src/managed.ts +101 -0
  168. package/src/mcp/apitools.ts +335 -0
  169. package/src/mcp/captools.ts +25 -0
  170. package/src/mcp/casetools.ts +223 -0
  171. package/src/mcp/commands.ts +421 -0
  172. package/src/mcp/exploretools.ts +129 -0
  173. package/src/mcp/external.ts +172 -0
  174. package/src/mcp/git.ts +78 -0
  175. package/src/mcp/notifytools.ts +49 -0
  176. package/src/mcp/prtools.ts +261 -0
  177. package/src/mcp/qualitytools.ts +154 -0
  178. package/src/mcp/repotests.ts +286 -0
  179. package/src/mcp/server.ts +234 -0
  180. package/src/mcp/tools.ts +810 -0
  181. package/src/memory.ts +250 -0
  182. package/src/money.ts +39 -0
  183. package/src/monitor/alerts.ts +94 -0
  184. package/src/monitor/background.ts +68 -0
  185. package/src/monitor/run.ts +263 -0
  186. package/src/monitor/state.ts +105 -0
  187. package/src/notify/events.ts +144 -0
  188. package/src/notify/notice.ts +177 -0
  189. package/src/prices.ts +175 -0
  190. package/src/print.ts +54 -0
  191. package/src/privacy.ts +35 -0
  192. package/src/prompts.ts +165 -0
  193. package/src/quality/budgets.ts +249 -0
  194. package/src/quality/history.ts +149 -0
  195. package/src/quality/visual.ts +201 -0
  196. package/src/report/data.ts +475 -0
  197. package/src/report/html.ts +106 -0
  198. package/src/report/template.html +84 -0
  199. package/src/run.ts +243 -0
  200. package/src/sessionLog.ts +216 -0
  201. package/src/sessionLogins.ts +94 -0
  202. package/src/skills/registry.ts +217 -0
  203. package/src/skills/scaffold.ts +65 -0
  204. package/src/skills/select.ts +37 -0
  205. package/src/skills/view.ts +44 -0
  206. package/src/suite/report.ts +135 -0
  207. package/src/suite/run.ts +303 -0
  208. package/src/summary.ts +188 -0
  209. package/src/tools.ts +130 -0
  210. package/src/tui/App.tsx +983 -0
  211. package/src/tui/clipboard.ts +36 -0
  212. package/src/tui/components.tsx +263 -0
  213. package/src/tui/dialogs.ts +65 -0
  214. package/src/tui/main.tsx +20 -0
  215. package/src/tui/text.ts +28 -0
  216. package/src/tui/theme.ts +104 -0
  217. package/src/tui/transcript.tsx +344 -0
  218. package/src/types.ts +146 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
@@ -0,0 +1,154 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import type { Card } from "../card.ts"
5
+ import { latestRunDir } from "../code/index.ts"
6
+ import { DEFAULT_QUALITY, DISCLAIMER, evaluateQuality, fmtMetric, IMPACTS, METRIC_LABEL, METRICS, limitsFor, type QualityReport } from "../quality/budgets.ts"
7
+ import { historyFile, qualityTrends, trendsCard } from "../quality/history.ts"
8
+ import { approveVisual, readCaptures, visualLine, type VisualReport } from "../quality/visual.ts"
9
+ import type { ToolContext } from "./tools.ts"
10
+ import type { McpTool } from "./tools.ts"
11
+
12
+ const VALUE = { lcp_ms: "lcp", cls: "cls", inp_ms: "inp", fcp_ms: "fcp", ttfb_ms: "ttfb", load_ms: "load" } as const
13
+
14
+ /** A run's accessibility and timings as a card: each WCAG rule that failed, the rules that passed, and each page's vitals. */
15
+ export function qualityCard(r: QualityReport, runDir: string, budgets: QualityReport["budgets"], perf: { budget?: Parameters<typeof limitsFor>[0] }): Card {
16
+ const body: string[] = []
17
+ if (r.accessibility.pages) {
18
+ body.push(`${r.standard} on ${r.accessibility.pages} page${r.accessibility.pages === 1 ? "" : "s"} (${r.engine}): ${r.accessibility.passed.length} rules passed, ${r.accessibility.failed.length} failed`)
19
+ for (const f of r.accessibility.failed.slice(0, 15)) body.push(` ✗ ${f.rule}${f.wcag ? ` · WCAG ${f.wcag}${f.level ? ` (${f.level})` : ""}` : ""} · ${f.impact} · ${f.count} element${f.count === 1 ? "" : "s"} on ${f.pages} page${f.pages === 1 ? "" : "s"}: ${f.help}`)
20
+ if (r.accessibility.failed.length > 15) body.push(` and ${r.accessibility.failed.length - 15} more`)
21
+ if (r.accessibility.passed.length) body.push(` ✓ ${r.accessibility.passed.slice(0, 20).join(", ")}${r.accessibility.passed.length > 20 ? ", …" : ""}`)
22
+ }
23
+ if (r.performance.pages.length) {
24
+ body.push("", "Timings (Core Web Vitals):")
25
+ for (const p of r.performance.pages) {
26
+ const lim = perf.budget ? limitsFor(perf.budget, p.url) : {}
27
+ const shown = METRICS.filter((m) => typeof p[VALUE[m]] === "number").map((m) => `${METRIC_LABEL[m]} ${fmtMetric(m, p[VALUE[m]] as number)}${lim[m] !== undefined ? `/${fmtMetric(m, lim[m]!)}` : ""}`)
28
+ body.push(` ${p.over.length ? "✗" : "•"} ${p.url}`, ` ${shown.join(" · ") || "not measured"}${p.requests ? ` · ${p.requests} requests, ${p.kb} KB` : ""}`)
29
+ }
30
+ }
31
+ if (r.breaches.length) body.push("", "Budgets exceeded:", ...r.breaches.map((b) => ` ✗ ${b.page}: ${b.text}`))
32
+ else if (budgets.accessibility || budgets.performance) body.push("", "Within budgets.")
33
+ else body.push("", "No budgets set: nothing fails on accessibility or timings. Set them under quality.budgets in forgeqe.yaml.")
34
+ body.push("", DISCLAIMER, `Run ${runDir}`)
35
+ return { status: r.breaches.length ? "fail" : "ok", title: `Accessibility and performance: ${r.breaches.length ? `${r.breaches.length} budget${r.breaches.length === 1 ? "" : "s"} exceeded` : "within budgets"}`, stats: `${IMPACTS.filter((i) => r.accessibility.byImpact[i]).map((i) => `${r.accessibility.byImpact[i]} ${i}`).join(", ") || "no violations"}`, body }
36
+ }
37
+
38
+ function runFolder(args: Record<string, any>, ctx: ToolContext): string {
39
+ const dir = args.run_dir ? path.resolve(ctx.cwd, String(args.run_dir)) : latestRunDir(ctx.config.export.outDir)
40
+ if (!dir || !existsSync(dir)) throw new Error("No run found. Run a flow in the browser first.")
41
+ return dir
42
+ }
43
+
44
+ const baselineDir = (ctx: ToolContext) => (ctx.config.quality || DEFAULT_QUALITY).visual.baselineDir || path.join(path.dirname(ctx.config.memory?.dir || ctx.config.export.outDir), "visual-baselines")
45
+
46
+ export function visualCard(v: VisualReport, runDir: string, cwd: string): Card {
47
+ const rel = (p: string) => {
48
+ const r = path.relative(cwd, p)
49
+ return r && !r.startsWith("..") ? r : p
50
+ }
51
+ const mark = { same: "✓", changed: "✗", new: "+", no_baseline: "?" } as const
52
+ const body: string[] = []
53
+ for (const p of v.pages) {
54
+ const what = p.status === "same" ? "same as its baseline" : p.status === "changed" ? `${p.diffPercent}% changed${p.limit !== undefined ? ` (budget ${p.limit}%)` : ""}${p.resized ? `, size ${p.resized}` : ""}` : p.status === "new" ? "first capture: now its baseline" : "no baseline (auto_baseline is off)"
55
+ body.push(`${p.status === "changed" && !p.over && p.limit !== undefined ? "~" : mark[p.status]} ${p.url} · ${what}`)
56
+ if (p.diff) body.push(` diff ${rel(p.diff)}`)
57
+ }
58
+ body.push("", `Baselines ${rel(v.baselineDir)}`, `Run ${rel(runDir)}`)
59
+ const over = v.pages.filter((p) => p.over).length
60
+ const changed = v.pages.filter((p) => p.status === "changed").length
61
+ return { status: over ? "fail" : changed ? "info" : "ok", title: `Visual comparison: ${over ? `${over} page${over === 1 ? "" : "s"} over budget` : changed ? `${changed} page${changed === 1 ? "" : "s"} changed` : "no changes"}`, stats: visualLine(v), body }
62
+ }
63
+
64
+ /** Accessibility and performance: a run's WCAG and Core Web Vitals report, and the trend over runs. */
65
+ export const QUALITY_TOOLS: McpTool[] = [
66
+ {
67
+ name: "quality_report",
68
+ description:
69
+ "A browser run's accessibility and performance: each WCAG rule that failed (success criterion, level, impact, elements, pages) and the rules that passed, from axe-core with the standard set in quality.accessibility.standard (default WCAG 2.1 AA); each page's Core Web Vitals and load timings (LCP, CLS, INP, FCP, TTFB, load); and the budgets in quality.budgets that were exceeded. Every run collects these; a run fails on them only when a budget is set and exceeded. Defaults to the latest run.",
70
+ inputSchema: { type: "object", properties: { run_dir: { type: "string", description: "Run folder (default: the latest run)" } } },
71
+ async run(args, ctx) {
72
+ const dir = args.run_dir ? path.resolve(ctx.cwd, String(args.run_dir)) : latestRunDir(ctx.config.export.outDir)
73
+ if (!dir || !existsSync(dir)) throw new Error("No run found. Run a flow in the browser first.")
74
+ const q = ctx.config.quality || DEFAULT_QUALITY
75
+ const r = evaluateQuality(dir, q)
76
+ if (!r) return { run_dir: dir, note: "This run has no accessibility or timing data (report.accessibility was off, or the run visited no page)." }
77
+ ctx.emit?.({ type: "card", card: qualityCard(r, dir, r.budgets, { budget: q.budgets.performance }) })
78
+ return {
79
+ run_dir: dir,
80
+ standard: r.standard,
81
+ engine: r.engine,
82
+ wcag: { passed_rules: r.accessibility.passed.length, failed_rules: r.accessibility.failed, violations_by_impact: r.accessibility.byImpact },
83
+ performance: r.performance.pages,
84
+ budgets_set: r.budgets,
85
+ budget_exceeded: r.breaches,
86
+ note: DISCLAIMER,
87
+ }
88
+ },
89
+ },
90
+ {
91
+ name: "quality_trends",
92
+ description:
93
+ "Accessibility and performance over time, per page, from every run's results: violations by impact and the rules that newly fail or were fixed since the previous run, and each Core Web Vital against its usual value over the last runs (slower or faster beyond noise). For 'is the site getting slower', 'did accessibility regress'.",
94
+ inputSchema: { type: "object", properties: { page: { type: "string", description: "Only pages whose URL contains this (a host or a path)" } } },
95
+ async run(args, ctx) {
96
+ const file = historyFile(ctx.config.export.outDir)
97
+ const trends = qualityTrends(file, args.page ? String(args.page) : undefined)
98
+ ctx.emit?.({ type: "card", card: trendsCard(trends, file, ctx.cwd) })
99
+ return {
100
+ pages: trends.map((t) => ({ page: t.page, runs: t.runs, accessibility: t.a11y.latest, accessibility_trend: t.a11y.trail, newly_failing_rules: t.a11y.newRules, fixed_rules: t.a11y.fixedRules, timings: t.perf.latest, usual: t.perf.usual, slower: t.perf.worse, faster: t.perf.better })),
101
+ history: file,
102
+ }
103
+ },
104
+ },
105
+ {
106
+ name: "visual_compare",
107
+ description:
108
+ "A browser run's visual comparison: each page it visited, captured with animations frozen and the masked regions (quality.visual.mask) hidden, against its approved baseline: same, changed (the share of pixels, with a diff image that marks them in red), or new (its first capture became the baseline). A run fails on it only when quality.budgets.visual sets the most a page may change. Defaults to the latest run.",
109
+ inputSchema: { type: "object", properties: { run_dir: { type: "string", description: "Run folder (default: the latest run)" } } },
110
+ async run(args, ctx) {
111
+ const dir = runFolder(args, ctx)
112
+ let v: VisualReport | null = null
113
+ try {
114
+ v = JSON.parse(readFileSync(path.join(dir, "visual-result.json"), "utf8")) as VisualReport
115
+ } catch {}
116
+ if (!v) return { run_dir: dir, note: readCaptures(dir).length ? "The run's pages were captured but not compared yet." : "This run has no visual captures (quality.visual.enabled was off, or the run visited no page)." }
117
+ ctx.emit?.({ type: "card", card: visualCard(v, dir, ctx.cwd) })
118
+ return { run_dir: dir, baselines: v.baselineDir, pages: v.pages.map((p) => ({ page: p.url, status: p.status, changed_percent: p.diffPercent, over_budget: p.over, resized: p.resized, current: p.current, baseline: p.baseline, diff: p.diff })), next_step: v.pages.some((p) => p.status === "changed") ? "Show the user what changed (the diff images). When the change is intended, offer visual_approve for those pages; when it is not, it is a visual defect." : undefined }
119
+ },
120
+ },
121
+ {
122
+ name: "visual_approve",
123
+ description: "Make a run's captures the new visual baselines, for all its pages or the pages whose URL contains one of pages: when a visual change is intended. Asks the user first.",
124
+ inputSchema: {
125
+ type: "object",
126
+ properties: {
127
+ run_dir: { type: "string", description: "Run folder (default: the latest run)" },
128
+ pages: { type: "array", items: { type: "string" }, description: "Only these pages (part of the URL); default: every page of the run" },
129
+ reason: { type: "string", description: "Why the change is intended, kept with the baseline" },
130
+ },
131
+ },
132
+ async run(args, ctx) {
133
+ const dir = runFolder(args, ctx)
134
+ const pages = Array.isArray(args.pages) ? args.pages.map(String) : []
135
+ const captures = readCaptures(dir).filter((c) => !pages.length || pages.some((p) => c.url.toLowerCase().includes(p.toLowerCase())))
136
+ if (!captures.length) throw new Error("No captured pages match.")
137
+ if (ctx.ask) {
138
+ const answer = await ctx.ask({
139
+ title: `Approve ${captures.length} page${captures.length === 1 ? "" : "s"} as the new visual baseline?`,
140
+ detail: "Later runs are compared with these captures.",
141
+ lines: captures.slice(0, 12).map((c) => `• ${c.url}`),
142
+ options: [
143
+ { id: "yes", label: "Approve" },
144
+ { id: "no", label: "Cancel" },
145
+ ],
146
+ })
147
+ if (answer !== "yes") return { cancelled: true }
148
+ }
149
+ const done = approveVisual(dir, baselineDir(ctx), pages, args.reason ? String(args.reason) : "approved")
150
+ ctx.emit?.({ type: "info", text: `${done.length} visual baseline${done.length === 1 ? "" : "s"} approved from ${path.basename(dir)}`, ok: true })
151
+ return { approved: done }
152
+ },
153
+ },
154
+ ]
@@ -0,0 +1,286 @@
1
+ import path from "node:path"
2
+
3
+ import { noAgentModel } from "../llm.ts"
4
+ import { privateEnvNames } from "../childEnv.ts"
5
+ import { findSprint, listSprints, recentSprints, sprintLabel, type Sprint } from "../cases/sprints.ts"
6
+ import { findTests, loadSelection, resultsCard, runSelection, selectionCard, type TestRange } from "../code/find.ts"
7
+ import { CAUSE_TEXT, flakyReport, matchTest, RELEASE_AFTER, setQuarantine } from "../code/history.ts"
8
+ import type { McpTool, ToolContext } from "./tools.ts"
9
+
10
+ const clip = (s: string | undefined, n: number) => {
11
+ const t = (s || "").trim()
12
+ return t.length <= n ? t : `…${t.slice(-(n - 1))}`
13
+ }
14
+
15
+ const DATE = /^\d{4}-\d{2}-\d{2}$/
16
+
17
+ const sprintSummary = (s: Sprint) => ({ name: s.name, state: s.state, start: s.start || undefined, end: s.end || undefined, system: s.system, team_or_board: s.where })
18
+
19
+ type SprintPick = { range: TestRange } | { stop: Record<string, unknown> }
20
+
21
+ /** The date range of the sprint the user means: named, or picked from the recent sprints of Azure DevOps and Jira. */
22
+ async function sprintRange(args: Record<string, any>, ctx: ToolContext): Promise<SprintPick> {
23
+ const { sprints, errors } = await listSprints(ctx.config.sources, { team: args.team, project: args.project, board: args.board })
24
+ if (!sprints.length) throw new Error(`No sprints found${errors.length ? `: ${errors.join("; ")}` : ""}. Ask the user for the dates and pass since and until.`)
25
+ const name = String(args.sprint).trim()
26
+ let picked: Sprint | undefined
27
+ if (name.toLowerCase() === "ask") {
28
+ const offer = recentSprints(sprints).slice(0, 8)
29
+ if (!ctx.ask) {
30
+ return {
31
+ stop: {
32
+ needs_choice: true,
33
+ sprints: offer.map(sprintSummary),
34
+ errors: errors.length ? errors : undefined,
35
+ next_step: "Ask the user which sprint, then call find_tests again with sprint set to its name.",
36
+ },
37
+ }
38
+ }
39
+ const choice = await ctx.ask({
40
+ title: "Which sprint?",
41
+ detail: "Tests added between the sprint's start and end dates are selected.",
42
+ lines: [...offer.map((s, i) => `${i + 1}. ${sprintLabel(s)}`), ...errors.map((e) => `Not available: ${e}`)],
43
+ options: [...offer.map((s, i) => ({ id: String(i), label: s.state === "current" ? `${s.name} (current)` : s.name })), { id: "cancel", label: "Cancel" }],
44
+ })
45
+ if (choice === null || choice === "cancel") return { stop: { cancelled: true, reason: "The user did not pick a sprint." } }
46
+ picked = offer[Number(choice)]
47
+ } else {
48
+ picked = findSprint(sprints, name)
49
+ if (!picked) throw new Error(`No sprint called "${name}". Recent sprints: ${recentSprints(sprints).map((s) => s.name).join(", ")}.`)
50
+ }
51
+ if (!picked?.start) throw new Error(`Sprint "${picked?.name}" has no start date; ask the user for the dates and pass since and until.`)
52
+ return { range: { since: picked.start, until: picked.end || undefined, label: sprintLabel(picked) } }
53
+ }
54
+
55
+ /** Find, confirm, and run a repository's existing automated tests. */
56
+ export const REPO_TEST_TOOLS: McpTool[] = [
57
+ {
58
+ name: "list_sprints",
59
+ description:
60
+ "Recent sprints from the configured Azure DevOps (team iterations) and Jira (scrum board sprints): name, state (current, past, future), start and end dates, and the team or board. For requests like 'tests added this sprint'.",
61
+ inputSchema: {
62
+ type: "object",
63
+ properties: {
64
+ team: { type: "string", description: "Azure DevOps team (default: the project's default team)" },
65
+ project: { type: "string", description: "Jira project key whose boards to read (default: every scrum board, up to five)" },
66
+ board: { type: "string", description: "Jira board id or name" },
67
+ all: { type: "boolean", description: "Every sprint instead of the current, three most recent past, and next ones" },
68
+ },
69
+ },
70
+ async run(args, ctx) {
71
+ const { sprints, errors } = await listSprints(ctx.config.sources, { team: args.team, project: args.project, board: args.board })
72
+ return { sprints: (args.all ? sprints : recentSprints(sprints)).map(sprintSummary), errors: errors.length ? errors : undefined }
73
+ },
74
+ },
75
+ {
76
+ name: "find_tests",
77
+ description:
78
+ "Find the automated tests already in a repository that match a request, without running them: a feature or topic ('login'), test case ids or tags, a folder, or tests added in a date range or sprint. Scribe, the coding agent, reads the tests (and git history for a range) and decides each one from what it does; ForgeQE runs the framework's list command to confirm the selection. Shows the list to the user and returns selection_id for run_tests. Takes a minute or two.",
79
+ inputSchema: {
80
+ type: "object",
81
+ properties: {
82
+ request: { type: "string", description: "What to find, in the user's words, e.g. 'tests related to login'" },
83
+ repo: { type: "string", description: "Repository, relative to the working directory (default: the working directory)" },
84
+ since: { type: "string", description: "Only tests added on or after this date, YYYY-MM-DD" },
85
+ until: { type: "string", description: "Only tests added on or before this date, YYYY-MM-DD" },
86
+ sprint: { type: "string", description: "Only tests added in a sprint: 'ask' lets the user pick from Azure DevOps and Jira sprints, or a sprint name" },
87
+ team: { type: "string", description: "Azure DevOps team for sprint" },
88
+ project: { type: "string", description: "Jira project key for sprint" },
89
+ board: { type: "string", description: "Jira board id or name for sprint" },
90
+ },
91
+ required: ["request"],
92
+ },
93
+ async run(args, ctx) {
94
+ const noModel = noAgentModel(ctx.config.llm, ctx.config.code.model, "Scribe")
95
+ if (noModel) throw new Error(noModel)
96
+ const request = String(args.request || "").trim()
97
+ if (!request) throw new Error("request is required.")
98
+ for (const k of ["since", "until"]) if (args[k] && !DATE.test(String(args[k]))) throw new Error(`${k} must be YYYY-MM-DD.`)
99
+ let range: TestRange | undefined
100
+ if (args.sprint) {
101
+ const pick = await sprintRange(args, ctx)
102
+ if ("stop" in pick) return pick.stop
103
+ range = pick.range
104
+ } else if (args.since || args.until) {
105
+ range = { since: args.since ? String(args.since) : undefined, until: args.until ? String(args.until) : undefined }
106
+ }
107
+ const sel = await findTests({
108
+ config: ctx.config,
109
+ repo: args.repo ? path.resolve(ctx.cwd, String(args.repo)) : ctx.cwd,
110
+ request,
111
+ range,
112
+ signal: ctx.signal,
113
+ onEvent: (event) => ctx.emit?.({ type: "agent", event }),
114
+ open: ctx.openSession as never,
115
+ runTest: ctx.runTest,
116
+ })
117
+ ctx.emit?.({ type: "card", card: selectionCard(sel, ctx.cwd) })
118
+ return {
119
+ selection_id: sel.id,
120
+ count: sel.tests.length,
121
+ framework: sel.framework,
122
+ range: range?.label || (range ? [range.since, range.until].filter(Boolean).join(" to ") : undefined),
123
+ tests: sel.tests,
124
+ left_out: sel.related.length ? sel.related : undefined,
125
+ list_command: sel.listCommand || undefined,
126
+ list_confirmed: sel.listed,
127
+ run_command: sel.runCommand,
128
+ notes: sel.notes || undefined,
129
+ next_step: sel.tests.length
130
+ ? ctx.ask
131
+ ? "The user sees the list. Call run_tests with this selection_id if they asked to run the tests: it asks them to confirm first."
132
+ : "Show the user the list and ask whether to run it; only after they say yes, call run_tests with this selection_id and confirmed: true."
133
+ : undefined,
134
+ }
135
+ },
136
+ },
137
+ {
138
+ name: "run_tests",
139
+ description:
140
+ "Run a selection from find_tests after the user confirms: ForgeQE asks the user (Run / Don't run), runs exactly those tests, and reads their JUnit or TRX results. Returns each test's pass or fail with the failure message, the output's end, and the result files. Where ForgeQE cannot ask (an MCP client), pass confirmed: true only after the user agreed to run this exact list.",
141
+ inputSchema: {
142
+ type: "object",
143
+ properties: {
144
+ selection_id: { type: "string", description: "From find_tests" },
145
+ confirmed: { type: "boolean", description: "Only where ForgeQE cannot ask: the user agreed to run this list" },
146
+ timeout_seconds: { type: "number", description: "Stop the run after this many seconds (default: code.test_timeout_seconds)" },
147
+ },
148
+ required: ["selection_id"],
149
+ },
150
+ async run(args, ctx) {
151
+ const sel = loadSelection(ctx.config, String(args.selection_id))
152
+ const n = sel.tests.length
153
+ if (!n) return { ran: false, reason: "The selection has no tests." }
154
+ const what = `${n} test${n === 1 ? "" : "s"}`
155
+ if (ctx.ask) {
156
+ const choice = await ctx.ask({
157
+ title: `Run ${what}?`,
158
+ detail: sel.runCommand,
159
+ lines: [`Selected for "${sel.request}":`, ...sel.tests.slice(0, 20).map((t) => ` • ${t.name}`), ...(n > 20 ? [` and ${n - 20} more`] : [])],
160
+ options: [
161
+ { id: "run", label: `Run ${what}` },
162
+ { id: "no", label: "Don't run" },
163
+ ],
164
+ })
165
+ if (choice !== "run") return { ran: false, reason: "The user chose not to run them." }
166
+ } else if (args.confirmed !== true) {
167
+ return {
168
+ ran: false,
169
+ needs_confirmation: true,
170
+ tests: sel.tests.map((t) => t.name),
171
+ run_command: sel.runCommand,
172
+ next_step: "Show the user this list and ask whether to run it; call run_tests again with confirmed: true only after they say yes.",
173
+ }
174
+ }
175
+ ctx.emit?.({ type: "agent", event: { type: "code", kind: "info", text: `ForgeQE runs ${what}: ${sel.runCommand}` } })
176
+ const timeout = Number(args.timeout_seconds) > 0 ? Number(args.timeout_seconds) : ctx.config.code.testTimeoutSeconds
177
+ const r = await runSelection({ selection: sel, timeoutSeconds: timeout, signal: ctx.signal, runTest: ctx.runTest, hide: privateEnvNames(ctx.config), flaky: ctx.config.flaky })
178
+ ctx.emit?.({ type: "card", card: resultsCard(r, ctx.cwd) })
179
+ const failed = r.tests.filter((t) => t.status === "failed")
180
+ return {
181
+ ran: true,
182
+ status: r.status,
183
+ passed: r.counts.passed,
184
+ failed: r.counts.failed,
185
+ skipped: r.counts.skipped,
186
+ seconds: r.seconds,
187
+ exit_code: r.exitCode,
188
+ failures: failed.length ? failed.map((t) => ({ name: t.name, file: t.file, message: t.message })) : undefined,
189
+ per_test_results: r.tests.length > 0,
190
+ result_files: r.resultFiles,
191
+ output: r.status === "passed" ? undefined : clip(r.output, 4000),
192
+ folder: r.dir,
193
+ ...(r.flaky?.length ? { flaky: r.flaky } : {}),
194
+ ...(r.quarantinedFailures?.length ? { quarantined_failures: r.quarantinedFailures } : {}),
195
+ ...(r.suggestQuarantine?.length ? { flaky_by_history: r.suggestQuarantine.map((f) => ({ name: f.name, trail: f.trail, cause: CAUSE_TEXT[f.cause], example: f.example })) } : {}),
196
+ ...(r.quarantinedNow?.length ? { quarantined_now: r.quarantinedNow } : {}),
197
+ ...(r.releasable?.length ? { ready_to_leave_quarantine: r.releasable } : {}),
198
+ next_step: [
199
+ r.status === "failed" ? `Offer heal_tests with command: ${sel.runCommand}` : "",
200
+ r.flaky?.length ? "Say which tests passed only when run again: they are flaky, not failing." : "",
201
+ r.suggestQuarantine?.length ? "Offer quarantine_tests for the flaky ones (it asks the user), and heal_tests to fix their cause." : "",
202
+ r.releasable?.length ? "Offer quarantine_tests with action release for the tests ready to leave quarantine." : "",
203
+ ].filter(Boolean).join(" ") || undefined,
204
+ }
205
+ },
206
+ },
207
+ {
208
+ name: "flaky_tests",
209
+ description:
210
+ "The repository's flaky tests, from the history ForgeQE keeps of every run_tests run (.forgeqe/test-history.json): each test's recent results (✓ passed, ✗ failed, ↻ passed only when run again), its pass rate and flakiness score, the likely cause from its failure messages (timing, an overlay covering the element, data that changes, network, the environment), with an example message, and which tests are in quarantine or ready to leave it. Shows a card. Nothing runs.",
211
+ inputSchema: {
212
+ type: "object",
213
+ properties: {
214
+ repo: { type: "string", description: "Repository, relative to the working directory (default: the working directory)" },
215
+ all: { type: "boolean", description: "Every test with history, not only flaky and quarantined ones" },
216
+ },
217
+ },
218
+ async run(args, ctx) {
219
+ const repo = args.repo ? path.resolve(ctx.cwd, String(args.repo)) : ctx.cwd
220
+ const after = ctx.config.flaky?.flakyAfter ?? 2
221
+ const report = flakyReport(repo, after)
222
+ const shown = args.all ? report : report.filter((f) => f.flaky || f.quarantined)
223
+ const body = shown.slice(0, 30).flatMap((f) => [
224
+ `${f.quarantined ? "■" : "⚠"} ${f.name} ${f.trail} ${Math.round(f.passRate * 100)}% passed${f.quarantined ? ` quarantined ${f.quarantined.at.slice(0, 10)}${f.releasable ? ", ready to leave" : ""}` : ""}`,
225
+ ` likely ${CAUSE_TEXT[f.cause]}${f.example ? `: ${f.example}` : ""}`,
226
+ ])
227
+ const flaky = report.filter((f) => f.flaky && !f.quarantined).length
228
+ const q = report.filter((f) => f.quarantined).length
229
+ ctx.emit?.({
230
+ type: "card",
231
+ card: {
232
+ status: flaky ? "fail" : "info",
233
+ title: report.length ? `${flaky} flaky test${flaky === 1 ? "" : "s"}, ${q} in quarantine` : "No test history yet",
234
+ stats: `${report.length} tests with history`,
235
+ body: report.length ? body : ["ForgeQE keeps each test's results from run_tests and pr-tests runs; run the tests a few times first."],
236
+ },
237
+ })
238
+ return {
239
+ tests: shown.map((f) => ({ name: f.name, trail: f.trail, runs: f.runs, pass_rate: f.passRate, score: f.score, flaky: f.flaky, cause: CAUSE_TEXT[f.cause], example: f.example, quarantined: f.quarantined, ready_to_leave_quarantine: f.releasable || undefined })),
240
+ next_step: flaky ? "Offer quarantine_tests for the flaky tests (it asks the user), and heal_tests with their cause to fix them." : undefined,
241
+ }
242
+ },
243
+ },
244
+ {
245
+ name: "quarantine_tests",
246
+ description:
247
+ `Put flaky tests in quarantine, or take them out (action release), after the user confirms. A quarantined test keeps running, so its history goes on, but its failure no longer fails run_tests or pr-tests; no test code changes. A quarantined test that passes ${RELEASE_AFTER} runs in a row is offered for release. Where ForgeQE cannot ask (an MCP client), pass confirmed: true only after the user agreed.`,
248
+ inputSchema: {
249
+ type: "object",
250
+ properties: {
251
+ tests: { type: "array", items: { type: "string" }, description: "Test names as flaky_tests or run_tests give them (a unique part of the name is enough)" },
252
+ action: { type: "string", enum: ["quarantine", "release"] },
253
+ reason: { type: "string", description: "Why, for quarantine: the likely cause" },
254
+ repo: { type: "string" },
255
+ confirmed: { type: "boolean" },
256
+ },
257
+ required: ["tests", "action"],
258
+ },
259
+ async run(args, ctx) {
260
+ const repo = args.repo ? path.resolve(ctx.cwd, String(args.repo)) : ctx.cwd
261
+ const given = (args.tests as string[]).map(String)
262
+ const names = given.map((g) => matchTest(repo, g))
263
+ const unknown = given.filter((_, i) => !names[i])
264
+ const known = names.filter((n): n is string => Boolean(n))
265
+ if (!known.length) throw new Error(`No test with history matches ${unknown.join(", ")}. flaky_tests lists them.`)
266
+ const release = args.action === "release"
267
+ if (ctx.ask) {
268
+ const choice = await ctx.ask({
269
+ title: release ? `Take ${known.length} test${known.length === 1 ? "" : "s"} out of quarantine?` : `Quarantine ${known.length} test${known.length === 1 ? "" : "s"}?`,
270
+ detail: release ? "Their failures will fail runs again." : "They keep running, but their failures stop failing runs and CI until they are released.",
271
+ lines: known.map((n) => ` • ${n}`),
272
+ options: [
273
+ { id: "yes", label: release ? "Release" : "Quarantine" },
274
+ { id: "no", label: "Cancel" },
275
+ ],
276
+ })
277
+ if (choice !== "yes") return { changed: [], reason: "The user cancelled." }
278
+ } else if (args.confirmed !== true) {
279
+ return { changed: [], needs_confirmation: true, tests: known, next_step: "Ask the user; call again with confirmed: true only after they agree." }
280
+ }
281
+ const changed = setQuarantine(repo, known, release ? null : String(args.reason || "flaky"), "user")
282
+ ctx.emit?.({ type: "info", text: `${release ? "Released from" : "Quarantined"}: ${changed.join(", ") || "nothing changed"}`, ok: true })
283
+ return { changed, ...(unknown.length ? { not_found: unknown } : {}) }
284
+ },
285
+ },
286
+ ]