@capagents/forgeqe 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +757 -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 +136 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
package/src/config.ts ADDED
@@ -0,0 +1,970 @@
1
+ import { setPromptDirs } from "./prompts.ts"
2
+ import { qualityConfig, type QualityConfig } from "./quality/budgets.ts"
3
+ import { existsSync, readFileSync } from "node:fs"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+
7
+ import { forgeqeHome, projectDir, renamed } from "./tools.ts"
8
+
9
+ import YAML from "yaml"
10
+
11
+ import type { LlmProfile } from "./llm.ts"
12
+ import type { ReplayMode } from "./types.ts"
13
+ import { loadDotEnv } from "./env.ts"
14
+ import { systemLocale } from "./locale.ts"
15
+ import { currencyCode, currencyForLocale } from "./money.ts"
16
+ import { listPrice, pricesDate, type DeploymentType, type ListPrice } from "./prices.ts"
17
+ import { ragConfig, type RagConfig } from "./cases/rag.ts"
18
+
19
+ export type BrowserConfig = {
20
+ headless: boolean
21
+ width: number
22
+ height: number
23
+ maxSteps: number
24
+ timeoutSeconds: number
25
+ visionDetail: "low" | "high"
26
+ allowedHosts: string[]
27
+ startUrl?: string
28
+ /** ForgeQE overlay in a visible browser: edge glow, a bar with the current step, the person's input locked. */
29
+ overlay: boolean
30
+ /** A second model call checks the finished run against the goal, and its verdict can fail a run the agent completed. */
31
+ judge: boolean
32
+ }
33
+
34
+ export type MemoryConfig = {
35
+ enabled: boolean
36
+ dir: string
37
+ replay: ReplayMode
38
+ }
39
+
40
+ function runtimeBin(value: unknown, base: string): string | undefined {
41
+ if (!value) return undefined
42
+ const s = String(value)
43
+ return /[\\/]/.test(s) ? path.resolve(base, s) : s
44
+ }
45
+
46
+ export type CodeConfig = {
47
+ /** Repo the coding agent writes the test into. Unset: no coding step after runs. */
48
+ repo?: string
49
+ maxAttempts: number
50
+ testTimeoutSeconds: number
51
+ /** Scribe's turn stops when one shell command it runs has failed this many times since ForgeQE last ran the test. */
52
+ stallAfter?: number
53
+ /** Give Scribe a Playwright-driven browser, set up like the run's, switched on once a step fails. */
54
+ liveBrowser?: boolean
55
+ /** Before Scribe starts, replay the run's steps as a Playwright script and hand over what worked and where it failed. */
56
+ replay?: boolean
57
+ /** Model for ForgeQE's agent and Scribe as provider/model, instead of the run's llms.json profile. */
58
+ model?: string
59
+ /** llms.json profile for the reviewer, which only reads and judges: a smaller, cheaper model is enough. */
60
+ reviewProfile?: string
61
+ /** Stop a coding job once it has used this many tokens (cached included), and report how far it got. */
62
+ maxTokens?: number
63
+ /** Most tool-calling steps in one of Scribe's turns (code.max_steps, default 50). */
64
+ maxSteps?: number
65
+ /** Move an unfinished test to a needs-attention folder (code.needs_attention, default true). */
66
+ needsAttention?: boolean
67
+ /** A working test with coding-standard issues gets one round to fix them (code.tidy, default true). */
68
+ tidy?: boolean
69
+ /** Agent runtime binary (`code.runtime`, or the older `code.opencode`); default: PATH, then the managed download. */
70
+ opencode?: string
71
+ /** Command that runs the repo's tests, for forgeqe heal; unset: the agent works it out. */
72
+ testCommand?: string
73
+ }
74
+
75
+ export type SourcesConfig = {
76
+ /** Post results back to Azure DevOps, Jira, or Xray after a suite. */
77
+ writeBack: boolean
78
+ /** Test cases the agent writes from an Azure DevOps or Jira story are saved there, linked to the story (`save_cases`, default true). */
79
+ saveCases?: boolean
80
+ /** `mcp`: give ForgeQE's agent Microsoft's Azure DevOps MCP server (default true), loading `mcpDomains`. `project` limits ForgeQE's own test, sprint, and bug calls; omit it and those calls cover every project. The MCP server is the organization either way. */
81
+ ado?: { orgUrl: string; project?: string; tokenEnv: string; mcp?: boolean; mcpDomains?: string[] }
82
+ /**
83
+ * `cloud` (*.atlassian.net): email + API token. Server/Data Center: personal access token.
84
+ * `mcp`: which Jira MCP server ForgeQE's agent gets (default mcp-atlassian), or false for none.
85
+ */
86
+ jira?: { url: string; cloud: boolean; emailEnv: string; tokenEnv: string; mcp?: JiraMcp | false }
87
+ /** Xray Cloud API; Xray Server/Data Center goes through the jira settings. */
88
+ xray: { url: string; clientIdEnv: string; clientSecretEnv: string }
89
+ /** Confluence pages as requirements for ForgeQE's agent. Cloud: email + API token; Server/DC: personal access token. */
90
+ confluence?: { url: string; cloud: boolean; emailEnv: string; tokenEnv: string; space?: string }
91
+ /** TestRail: email + API key. */
92
+ testrail?: { url: string; emailEnv: string; tokenEnv: string; projectId?: number }
93
+ /** Zephyr Scale Cloud: API access token. */
94
+ zephyr?: { url: string; tokenEnv: string; projectKey?: string }
95
+ /** qTest Manager: API bearer token. `statuses` are the automation result names mapped in qTest. */
96
+ qtest?: { url: string; tokenEnv: string; projectId: number; statuses: { passed: string; failed: string; error: string } }
97
+ /** File a bug for each failed case, or comment on the open one ForgeQE filed before. */
98
+ bugs?: BugsConfig
99
+ }
100
+
101
+ export type BugsConfig = {
102
+ system: "jira" | "azure_devops"
103
+ /** Jira project key. Azure DevOps uses this, or sources.azure_devops.project. */
104
+ project?: string
105
+ /** Jira issue type or Azure DevOps work item type. */
106
+ type: string
107
+ labels: string[]
108
+ /** Azure DevOps area path. */
109
+ areaPath?: string
110
+ /** Also file bugs for cases that could not run (status error). */
111
+ onError: boolean
112
+ }
113
+
114
+ /** A site the browser agent can sign in to. Values come from the environment; the agent only sees placeholders. */
115
+ export type LoginConfig = {
116
+ name: string
117
+ /** Engine domain patterns, e.g. https://staging.example.com or https://*.example.com. */
118
+ domains: string[]
119
+ url?: string
120
+ usernameEnv?: string
121
+ passwordEnv?: string
122
+ /** Authenticator (TOTP) secret, base32. */
123
+ totpEnv?: string
124
+ /** Cookies and local storage kept between runs; unset when save_session is false. */
125
+ sessionFile?: string
126
+ }
127
+
128
+ export type ReportConfig = {
129
+ /** Step screenshots in reports: every step, only failed runs, or none. */
130
+ screenshots: "all" | "failures" | "off"
131
+ /** Accessibility checks on each page the browser agent visits. */
132
+ accessibility: boolean
133
+ }
134
+
135
+ export type ReleaseThresholds = {
136
+ /** Open bugs of severity 1 (critical) and 2 (high) allowed. */
137
+ maxCritical: number
138
+ maxHigh: number
139
+ /** Executed test cases that must pass, in percent. */
140
+ minPassRate: number
141
+ /** A story with no test case is a risk (true) or ignored. */
142
+ requireTests: boolean
143
+ }
144
+
145
+ export const DEFAULT_THRESHOLDS: ReleaseThresholds = { maxCritical: 0, maxHigh: 0, minPassRate: 95, requireTests: true }
146
+
147
+ function releaseThresholds(raw: unknown): ReleaseThresholds {
148
+ const r = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {}
149
+ const num = (v: unknown, d: number) => (v === undefined || v === null || v === "" || Number.isNaN(Number(v)) ? d : Number(v))
150
+ return {
151
+ maxCritical: num(r.max_critical, DEFAULT_THRESHOLDS.maxCritical),
152
+ maxHigh: num(r.max_high, DEFAULT_THRESHOLDS.maxHigh),
153
+ minPassRate: num(r.min_pass_rate, DEFAULT_THRESHOLDS.minPassRate),
154
+ requireTests: r.require_tests !== false,
155
+ }
156
+ }
157
+
158
+ export type FlakyConfig = {
159
+ /** Runs of a failed test in the same run, to tell a flaky failure from a real one (flaky.retries, default 1). */
160
+ retries: number
161
+ /** suggest (default): the agent offers to quarantine a flaky test; auto: ForgeQE does; off: history only. */
162
+ quarantine: "suggest" | "auto" | "off"
163
+ /** Flakiness score (passes on retry, plus pairs of flips, in the last 20 runs) from which a test counts as flaky. */
164
+ flakyAfter: number
165
+ }
166
+
167
+ export type ApiConfig = {
168
+ /** Record the application's API calls in every browser run (network.json), for API and contract tests (api.capture, default on). */
169
+ capture: boolean
170
+ /** Base URL contracts run against when it is not the one they were recorded or described with. */
171
+ baseUrl?: string
172
+ /** Headers every API call sends, with ${NAME} for values from the environment (e.g. Authorization: Bearer ${API_TOKEN}). */
173
+ headers: Record<string, string>
174
+ /** Calls to other sites than the page's (CDNs, identity providers) are left out of contracts unless this is on. */
175
+ thirdParty: boolean
176
+ timeoutSeconds: number
177
+ }
178
+
179
+ export type SuiteConfig = {
180
+ /** Browser runs at the same time. Coding runs one at a time. */
181
+ parallel: number
182
+ /** Write a test for each passed case when a repo is set. */
183
+ code: boolean
184
+ outDir: string
185
+ /** Check test cases before running them: warn (show the findings), block (skip cases that cannot run), or off. */
186
+ caseCheck?: "warn" | "block" | "off"
187
+ }
188
+
189
+ export type SkillsConfig = {
190
+ enabled: boolean
191
+ /** .forgeqe/skills next to forgeqe.yaml (the current folder without one). */
192
+ projectDir: string
193
+ /** ~/.forgeqe/skills, or under FORGEQE_HOME. */
194
+ userDir: string
195
+ /** `skills.dirs`, resolved against forgeqe.yaml; they win over every other source. */
196
+ dirs: string[]
197
+ /** Skill names turned off. */
198
+ disable: string[]
199
+ /** Also the skills other tools use: ~/.claude/skills, ~/.agents/skills, and the repository's .claude/.agents skills. */
200
+ external: boolean
201
+ /** Cap on the skill text added to the browser agent's prompt. */
202
+ browserMaxChars: number
203
+ }
204
+
205
+ export type MonitorConfig = {
206
+ /** Test case sources checked every round, as forgeqe suite takes them. */
207
+ checks: string[]
208
+ /** Time from the start of one round to the start of the next. */
209
+ everyMs: number
210
+ replay: ReplayMode
211
+ parallel: number
212
+ /** Failing rounds in a row before a check's first alert. */
213
+ failAfter: number
214
+ /** Alert again while a check keeps failing; 0: never. */
215
+ remindMs: number
216
+ /** Round folders kept in outDir; older ones are deleted. */
217
+ keep: number
218
+ writeBack: boolean
219
+ outDir: string
220
+ /** Where outDir is served, so alerts can link to each round's report. */
221
+ reportUrl?: string
222
+ alerts: MonitorAlerts
223
+ }
224
+
225
+ export type MonitorAlerts = {
226
+ /** A Teams incoming webhook or Workflows "when a webhook request is received" URL, from the environment. */
227
+ teams?: { webhookEnv: string }
228
+ email?: EmailConfig
229
+ }
230
+
231
+ /** What a channel is told about without being asked (notify: in forgeqe.yaml). */
232
+ export const NOTIFY_EVENTS = ["suite_failed", "suite_passed", "release", "eval_failed"] as const
233
+ export type NotifyEvent = (typeof NOTIFY_EVENTS)[number]
234
+
235
+ /** A named place notifications go: a Teams channel's webhook, email, or both, and the events it gets. */
236
+ export type NotifyChannel = MonitorAlerts & { name: string; on: NotifyEvent[] }
237
+
238
+ export type NotifyConfig = {
239
+ /** Where the reports folder is published, so cards link to each report; in CI the build's page is linked too. */
240
+ reportUrl?: string
241
+ channels: NotifyChannel[]
242
+ }
243
+
244
+ function notifyConfig(raw: unknown): NotifyConfig | undefined {
245
+ const n = asRecord(raw)
246
+ const channels = Object.entries(asRecord(n.channels)).map(([name, value]): NotifyChannel => {
247
+ const c = asRecord(value)
248
+ const where = `notify.channels.${name}`
249
+ const alerts = monitorAlerts(c, where)
250
+ if (!alerts.teams && !alerts.email) throw new Error(`${where} needs teams (a webhook_env) or email.`)
251
+ const on = c.on === undefined ? ["suite_failed", "release", "eval_failed"] : (Array.isArray(c.on) ? c.on : [c.on]).map(String)
252
+ const unknown = on.filter((e) => !(NOTIFY_EVENTS as readonly string[]).includes(e))
253
+ if (unknown.length) throw new Error(`${where}.on: ${unknown.join(", ")} is not an event. Events: ${NOTIFY_EVENTS.join(", ")}.`)
254
+ return { name, ...alerts, on: on as NotifyEvent[] }
255
+ })
256
+ if (!channels.length) return undefined
257
+ return { channels, ...(n.report_url ? { reportUrl: trimUrl(n.report_url) } : {}) }
258
+ }
259
+
260
+ export type EmailConfig = {
261
+ host: string
262
+ port: number
263
+ /** TLS from the start (port 465); otherwise STARTTLS when the server offers it. */
264
+ secure: boolean
265
+ usernameEnv: string
266
+ passwordEnv: string
267
+ /** Default: the SMTP user name. */
268
+ from?: string
269
+ to: string[]
270
+ }
271
+
272
+ export type JiraMcp = "mcp-atlassian" | "rovo"
273
+
274
+ /** An extra MCP server for ForgeQE's agent. Values may use {env:NAME}; the agent runtime fills them in. */
275
+ export type ExtraMcpServer =
276
+ | { type: "local"; command: string[]; environment?: Record<string, string>; cwd?: string; timeout?: number; enabled?: boolean }
277
+ | { type: "remote"; url: string; headers?: Record<string, string>; timeout?: number; enabled?: boolean }
278
+
279
+ export type AppConfig = {
280
+ configPath?: string
281
+ /** `mcp:` in forgeqe.yaml: more MCP servers for ForgeQE's agent, by name. */
282
+ mcp?: Record<string, ExtraMcpServer>
283
+ activeProfile: string
284
+ profiles: Record<string, LlmProfile>
285
+ llm: LlmProfile
286
+ browser: BrowserConfig
287
+ /**
288
+ * stepInstruction and guiderails are added to the browser agent's prompt. dirs: where edited prompts are looked for,
289
+ * highest precedence first (prompts.dir, the project's .forgeqe/prompts, ~/.forgeqe/prompts); see src/prompts.ts.
290
+ */
291
+ prompts: { stepInstruction: string; guiderails: string; dirs?: { dir: string; source: string }[] }
292
+ /** Product knowledge (.forgeqe/knowledge): see src/knowledge.ts. */
293
+ knowledge?: { dir: string; browserMaxChars: number }
294
+ export: { outDir: string; format: string }
295
+ memory: MemoryConfig
296
+ code: CodeConfig
297
+ sources: SourcesConfig
298
+ suite: SuiteConfig
299
+ logins: LoginConfig[]
300
+ /** Where saved browser sessions are kept: .forgeqe/sessions next to forgeqe.yaml. */
301
+ sessionsDir?: string
302
+ /** Session logs for debugging: .forgeqe/logs next to forgeqe.yaml. */
303
+ logsDir?: string
304
+ report: ReportConfig
305
+ monitor: MonitorConfig
306
+ skills: SkillsConfig
307
+ /** An optional requirements search (RAG) the test case check asks for passages: see src/cases/rag.ts. */
308
+ rag?: RagConfig
309
+ /** Flaky tests: retries in one run, and what to do with tests that keep flip-flopping (see src/code/history.ts). */
310
+ flaky?: FlakyConfig
311
+ /** Thresholds of the release-readiness verdict (release: in forgeqe.yaml). */
312
+ release?: ReleaseThresholds
313
+ /** API and contract tests (api: in forgeqe.yaml): see src/api/. */
314
+ api?: ApiConfig
315
+ /** Accessibility standard and accessibility/performance budgets (quality: in forgeqe.yaml): see src/quality/. */
316
+ quality?: QualityConfig
317
+ /** The product's industry and the regulations in scope (domain: in forgeqe.yaml): see src/domain.ts. */
318
+ domain?: DomainConfig
319
+ /** Teams and email notifications for suites, release readiness, and evaluations (notify: in forgeqe.yaml): see src/notify/. */
320
+ notify?: NotifyConfig
321
+ }
322
+
323
+ export type DomainConfig = {
324
+ /** In the team's words, e.g. "retail banking", "property and casualty insurance". */
325
+ industry: string
326
+ /** Regulations and standards in scope, e.g. DORA, PSD2, GDPR, ISO 27001. Their applicable controls go in the knowledge folder. */
327
+ regulations: string[]
328
+ /** Anything else the agents should know about the domain, in one or two sentences. */
329
+ notes?: string
330
+ }
331
+
332
+ function domainConfig(raw: unknown): DomainConfig | undefined {
333
+ const d = asRecord(raw)
334
+ const industry = String(d.industry || "").trim()
335
+ const list = Array.isArray(d.regulations) ? d.regulations : typeof d.regulations === "string" ? d.regulations.split(",") : []
336
+ const regulations = [...new Set(list.map((r) => String(r).trim()).filter(Boolean))]
337
+ if (!industry && !regulations.length) return undefined
338
+ return { industry, regulations, ...(d.notes ? { notes: String(d.notes).trim() } : {}) }
339
+ }
340
+
341
+ export function replayMode(raw: unknown): ReplayMode {
342
+ const mode = String(raw || "verify").toLowerCase()
343
+ if (mode === "trust" || mode === "off") return mode
344
+ if (mode !== "verify") throw new Error(`Unknown replay mode "${String(raw)}". Use verify, trust or off.`)
345
+ return "verify"
346
+ }
347
+
348
+ const MOCK_PROFILE: LlmProfile = { provider: "mock", model: "mock", temperature: 0 }
349
+ const NO_PROFILE: LlmProfile = { provider: "none", model: "" }
350
+
351
+ export function userConfigDir(): string {
352
+ return process.env.FORGEQE_CONFIG_DIR || renamed(path.join(os.homedir(), ".config"), "forgeqe", "webpilot")
353
+ }
354
+
355
+ function asRecord(value: unknown): Record<string, unknown> {
356
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
357
+ }
358
+
359
+ function readJson(file: string): Record<string, unknown> {
360
+ if (!existsSync(file)) return {}
361
+ const data = JSON.parse(readFileSync(file, "utf8"))
362
+ return asRecord(data)
363
+ }
364
+
365
+ function readYaml(file: string): Record<string, unknown> {
366
+ if (!existsSync(file)) return {}
367
+ return asRecord(YAML.parse(readFileSync(file, "utf8")))
368
+ }
369
+
370
+ function visionDetail(raw: unknown): BrowserConfig["visionDetail"] {
371
+ return String(raw || "low").toLowerCase() === "high" ? "high" : "low"
372
+ }
373
+
374
+ function profileFromRaw(name: string, raw: Record<string, unknown>): LlmProfile {
375
+ const provider = String(raw.provider || "openai") as LlmProfile["provider"]
376
+ return {
377
+ provider,
378
+ model: String(raw.model || "gpt-4o"),
379
+ endpoint: raw.endpoint ? String(raw.endpoint) : undefined,
380
+ deployment: raw.deployment ? String(raw.deployment) : undefined,
381
+ apiKey: raw.api_key ? String(raw.api_key) : undefined,
382
+ apiKeyEnv: raw.api_key_env ? String(raw.api_key_env) : undefined,
383
+ ...(String(raw.auth || "").toLowerCase() === "entra" ? { auth: "entra" as const } : {}),
384
+ managedIdentityClientId: raw.managed_identity_client_id ? String(raw.managed_identity_client_id) : undefined,
385
+ tokenScope: raw.token_scope ? String(raw.token_scope) : undefined,
386
+ apiVersion: raw.api_version ? String(raw.api_version) : undefined,
387
+ temperature: typeof raw.temperature === "number" ? raw.temperature : undefined,
388
+ ...priceFor(name, raw, provider, String(raw.model || "gpt-4o")),
389
+ }
390
+ }
391
+
392
+ /**
393
+ * A profile's price: llms.json's when it sets one (a negotiated rate wins), with the cached-input price filled from
394
+ * the model's list discount when llms.json leaves it out; otherwise the model's list price. Also a warning when
395
+ * llms.json's price differs from the list price, which is usually a typo or an old price.
396
+ */
397
+ function priceFor(name: string, raw: Record<string, unknown>, provider: string, model: string): Pick<LlmProfile, "price" | "priceWarning"> {
398
+ const deployment: DeploymentType = /data.?zone|^dz$/i.test(String(raw.deployment_type || "")) ? "data_zone" : "global"
399
+ const list = listPrice(provider, model, deployment)
400
+ const date = pricesDate()
401
+ const where = list ? `${list.source}${date ? `, ${date}` : ""}` : ""
402
+ const own = priceFromRaw(name, raw.price)
403
+ if (!own) {
404
+ if (!list) return {}
405
+ return {
406
+ price: {
407
+ input: list.input,
408
+ output: list.output,
409
+ currency: "USD",
410
+ ...(list.cacheRead !== undefined ? { cachedInput: list.cacheRead } : {}),
411
+ ...(list.cacheWrite !== undefined ? { cacheWrite: list.cacheWrite } : {}),
412
+ note: `list price of ${model}${provider === "azure" ? (deployment === "data_zone" ? " (Azure Data Zone)" : " (Azure Global)") : ""} from ${where}`,
413
+ },
414
+ }
415
+ }
416
+ const price = { ...own }
417
+ // A list in US dollars can only be compared with, or fill in, a price in US dollars.
418
+ const usable = list && own.currency === "USD" ? list : null
419
+ if (usable && price.cachedInput === undefined && usable.cacheRead !== undefined && usable.input > 0) {
420
+ const share = usable.cacheRead / usable.input
421
+ price.cachedInput = Math.round(price.input * share * 1e6) / 1e6
422
+ price.estimate = true
423
+ price.note = `cached input at ${Math.round(share * 100)}% of the input price, ${model}'s cache discount in its list price (${where})`
424
+ }
425
+ if (usable && price.cacheWrite === undefined && usable.cacheWrite !== undefined && usable.input > 0) price.cacheWrite = Math.round(price.input * (usable.cacheWrite / usable.input) * 1e6) / 1e6
426
+ return { price, ...(usable ? priceWarning(name, model, own, usable, where) : {}) }
427
+ }
428
+
429
+ function priceWarning(name: string, model: string, own: NonNullable<LlmProfile["price"]>, list: ListPrice, where: string): Pick<LlmProfile, "priceWarning"> {
430
+ const off = (a: number | undefined, b: number | undefined) => a !== undefined && b !== undefined && Math.abs(a - b) > Math.max(0.005, b * 0.01)
431
+ const diffs = [
432
+ off(own.input, list.input) ? `input ${money(own.input)} (list ${money(list.input)})` : "",
433
+ off(own.output, list.output) ? `output ${money(own.output)} (list ${money(list.output)})` : "",
434
+ off(own.cachedInput, list.cacheRead) ? `cached input ${money(own.cachedInput!)} (list ${money(list.cacheRead!)})` : "",
435
+ ].filter(Boolean)
436
+ if (!diffs.length) return {}
437
+ return {
438
+ priceWarning: `llms.json prices "${name}" (${model}) at ${diffs.join(", ")} per million tokens; the list price is from ${where}. Keep it for a negotiated rate, or remove the price to use the list price.`,
439
+ }
440
+ }
441
+
442
+ const money = (n: number) => `$${n < 1 ? n.toFixed(3).replace(/0+$/, "").replace(/\.$/, "") : n.toFixed(2)}`
443
+
444
+ function priceFromRaw(profile: string, raw: unknown): LlmProfile["price"] {
445
+ if (raw === undefined || raw === null) return undefined
446
+ const p = asRecord(raw)
447
+ const input = Number(p.input)
448
+ const output = Number(p.output)
449
+ if (!Number.isFinite(input) || !Number.isFinite(output) || input < 0 || output < 0) {
450
+ throw new Error(`llms.json: "${profile}".price needs input and output, the price per million tokens (e.g. { "input": 2, "output": 8 }).`)
451
+ }
452
+ const currency = p.currency ? currencyCode(String(p.currency)) : currencyForLocale(systemLocale())
453
+ if (!currency) throw new Error(`llms.json: "${profile}".price.currency must be an ISO currency code (USD, EUR, GBP, …). Leave it out to use this machine's currency.`)
454
+ const rawCached = p.cached_input ?? p.cachedInput
455
+ if (rawCached === undefined || rawCached === null) return { input, output, currency }
456
+ const cachedInput = Number(rawCached)
457
+ if (!Number.isFinite(cachedInput) || cachedInput < 0) throw new Error(`llms.json: "${profile}".price.cached_input must be the price per million cached input tokens (e.g. 0.25).`)
458
+ return { input, output, currency, cachedInput }
459
+ }
460
+
461
+ /** Profiles from llms.json, without the mock entries older templates wrote; fallback is "" when there are none. */
462
+ function loadProfiles(modelsFile: string | undefined): { profiles: Record<string, LlmProfile>; fallback: string } {
463
+ if (!modelsFile || !existsSync(modelsFile)) return { profiles: {}, fallback: "" }
464
+ const doc = readJson(modelsFile)
465
+ const models = asRecord(doc.models)
466
+ const profiles: Record<string, LlmProfile> = {}
467
+ for (const [name, value] of Object.entries(models)) {
468
+ const profile = profileFromRaw(name, asRecord(value))
469
+ if (profile.provider !== "mock" && name !== "mock") profiles[name] = profile
470
+ }
471
+ const fallback = typeof doc.default === "string" && profiles[doc.default] ? doc.default : Object.keys(profiles)[0] || ""
472
+ return { profiles, fallback }
473
+ }
474
+
475
+ function findConfigFile(explicit?: string): string | undefined {
476
+ if (explicit) {
477
+ const file = path.resolve(explicit)
478
+ if (!existsSync(file)) throw new Error(`Config not found: ${file}`)
479
+ return file
480
+ }
481
+ if (process.env.WP_CONFIG) {
482
+ const file = path.resolve(process.env.WP_CONFIG)
483
+ if (!existsSync(file)) throw new Error(`Config not found: ${file}`)
484
+ return file
485
+ }
486
+ const local = renamed(process.cwd(), "forgeqe.yaml", "webpilot.yaml")
487
+ if (existsSync(local)) return local
488
+ const global = renamed(userConfigDir(), "forgeqe.yaml", "webpilot.yaml")
489
+ if (existsSync(global)) return global
490
+ return undefined
491
+ }
492
+
493
+ /** Tokens one coding job (Scribe and its reviewer) may use when forgeqe.yaml sets no code.max_tokens. */
494
+ export const DEFAULT_CODE_MAX_TOKENS = 1_500_000
495
+
496
+ /** code.max_tokens: unset is the default budget, 0 (or less) turns the budget off. */
497
+ function codeBudget(raw: unknown): number | undefined {
498
+ const n = raw === undefined || raw === null || raw === "" ? DEFAULT_CODE_MAX_TOKENS : Number(raw)
499
+ return n > 0 ? n : undefined
500
+ }
501
+
502
+ export function loadConfig(opts?: { configPath?: string; profile?: string }): AppConfig {
503
+ // FORGEQE_CWD_ENV=off: tests never read the .env of the folder they run in (the developer's own keys).
504
+ if (process.env.FORGEQE_CWD_ENV !== "off") loadDotEnv(process.cwd())
505
+ const configPath = findConfigFile(opts?.configPath)
506
+ if (configPath) loadDotEnv(path.dirname(configPath))
507
+ loadDotEnv(userConfigDir())
508
+
509
+ const yaml = configPath ? readYaml(configPath) : {}
510
+ const base = configPath ? path.dirname(configPath) : process.cwd()
511
+ const llm = asRecord(yaml.llm)
512
+ const browser = asRecord(yaml.browser)
513
+ const viewport = asRecord(browser.viewport)
514
+ const prompts = asRecord(yaml.prompts)
515
+ const exp = asRecord(yaml.export)
516
+ const memory = asRecord(yaml.memory)
517
+ const code = asRecord(yaml.code)
518
+ const sources = asRecord(yaml.sources)
519
+ const suite = asRecord(yaml.suite)
520
+ const outDir = exp.out_dir ? path.resolve(base, String(exp.out_dir)) : path.join(projectDir(base), "reports")
521
+
522
+ const modelsRel = process.env.WP_LLMS_JSON || (llm.models_file ? String(llm.models_file) : "./llms.json")
523
+ const projectModels = path.resolve(base, modelsRel)
524
+ const globalModels = path.join(userConfigDir(), "llms.json")
525
+ const modelsFile = existsSync(projectModels) ? projectModels : existsSync(globalModels) ? globalModels : undefined
526
+ const { profiles, fallback } = loadProfiles(modelsFile)
527
+
528
+ const asked = opts?.profile || process.env.WP_LLM_PROFILE
529
+ if (asked === "mock") profiles.mock = { ...MOCK_PROFILE }
530
+ const active = llm.active && String(llm.active) !== "mock" ? String(llm.active) : ""
531
+ const requested = asked || active || fallback
532
+ const activeProfile = profiles[requested] ? requested : fallback
533
+ if (opts?.profile && !profiles[opts.profile]) {
534
+ throw new Error(`Unknown LLM profile "${opts.profile}". Run: forgeqe models`)
535
+ }
536
+ if (code.review_profile && !profiles[String(code.review_profile)]) {
537
+ throw new Error(`code.review_profile in forgeqe.yaml names "${code.review_profile}", which is not a profile in llms.json. Run: forgeqe models`)
538
+ }
539
+
540
+ const hosts = Array.isArray(browser.allowed_hosts)
541
+ ? browser.allowed_hosts.map((h) => String(h)).filter(Boolean)
542
+ : []
543
+
544
+ const sessionsDir = configPath ? path.join(projectDir(base), "sessions") : path.join(forgeqeHome(), "sessions")
545
+ const config: AppConfig = {
546
+ configPath,
547
+ mcp: extraMcpServers(yaml.mcp, base),
548
+ activeProfile,
549
+ profiles,
550
+ llm: profiles[activeProfile] || { ...NO_PROFILE },
551
+ browser: {
552
+ headless: browser.headless !== false,
553
+ width: Number(viewport.width || 1280),
554
+ height: Number(viewport.height || 800),
555
+ maxSteps: Number(browser.max_steps || 40),
556
+ timeoutSeconds: Number(browser.timeout_seconds || 600),
557
+ visionDetail: visionDetail(browser.vision_detail),
558
+ allowedHosts: hosts,
559
+ startUrl: browser.start_url ? String(browser.start_url) : undefined,
560
+ overlay: browser.overlay !== false,
561
+ judge: browser.judge === true,
562
+ },
563
+ prompts: {
564
+ stepInstruction: String(prompts.step_instruction || ""),
565
+ guiderails: String(prompts.guiderails || ""),
566
+ dirs: promptDirsFor(prompts, base, configPath),
567
+ },
568
+ knowledge: knowledgeConfig(asRecord(yaml.knowledge), base, configPath),
569
+ export: {
570
+ outDir,
571
+ format: String(exp.format || "playwright"),
572
+ },
573
+ memory: {
574
+ enabled: memory.enabled !== false,
575
+ dir: memory.dir
576
+ ? path.resolve(base, String(memory.dir))
577
+ : configPath
578
+ ? path.join(projectDir(base), "memory")
579
+ : path.join(forgeqeHome(), "memory"),
580
+ replay: replayMode(memory.replay),
581
+ },
582
+ code: {
583
+ repo: code.repo ? path.resolve(base, String(code.repo)) : undefined,
584
+ maxAttempts: Number(code.max_attempts || 3),
585
+ testTimeoutSeconds: Number(code.test_timeout_seconds || 600),
586
+ stallAfter: Math.max(2, Number(code.stall_after || 5)),
587
+ liveBrowser: code.live_browser !== false,
588
+ replay: code.replay !== false,
589
+ model: code.model ? String(code.model) : undefined,
590
+ ...(code.review_profile ? { reviewProfile: String(code.review_profile) } : {}),
591
+ ...(codeBudget(code.max_tokens) ? { maxTokens: codeBudget(code.max_tokens) } : {}),
592
+ maxSteps: Number(code.max_steps) > 0 ? Number(code.max_steps) : 50,
593
+ needsAttention: code.needs_attention !== false,
594
+ tidy: code.tidy !== false,
595
+ opencode: runtimeBin(code.runtime || code.opencode, base),
596
+ ...(code.test_command ? { testCommand: String(code.test_command) } : {}),
597
+ },
598
+ sources: sourcesConfig(sources),
599
+ ...(ragConfig(yaml.rag) ? { rag: ragConfig(yaml.rag) } : {}),
600
+ flaky: flakyConfig(asRecord(yaml.flaky)),
601
+ release: releaseThresholds(yaml.release),
602
+ api: apiConfig(asRecord(yaml.api)),
603
+ quality: withBaselineDir(qualityConfig(yaml.quality), base, configPath),
604
+ domain: domainConfig(yaml.domain),
605
+ notify: notifyConfig(yaml.notify),
606
+ suite: {
607
+ parallel: Math.max(1, Number(suite.parallel || 1)),
608
+ code: suite.code !== false,
609
+ caseCheck: suite.case_check === "block" || suite.case_check === "off" || suite.case_check === false ? (suite.case_check === false ? "off" : suite.case_check) : "warn",
610
+ outDir: suite.out_dir ? path.resolve(base, String(suite.out_dir)) : path.join(outDir, "suites"),
611
+ },
612
+ logins: loginsConfig(yaml.logins, sessionsDir),
613
+ sessionsDir,
614
+ logsDir: path.join(path.dirname(sessionsDir), "logs"),
615
+ report: { screenshots: screenshotsMode(asRecord(yaml.report).screenshots), accessibility: asRecord(yaml.report).accessibility !== false },
616
+ monitor: monitorConfig(asRecord(yaml.monitor), base, outDir),
617
+ skills: skillsConfig(yaml.skills, base),
618
+ }
619
+ // Prompts resolve from these folders from now on (src/prompts.ts).
620
+ setPromptDirs(config.prompts.dirs || [])
621
+ return config
622
+ }
623
+
624
+ function skillsConfig(raw: unknown, base: string): SkillsConfig {
625
+ const s = asRecord(raw)
626
+ const list = (v: unknown) => (Array.isArray(v) ? v.map((x) => String(x).trim()).filter(Boolean) : [])
627
+ return {
628
+ enabled: raw !== false && s.enabled !== false,
629
+ projectDir: path.join(projectDir(base), "skills"),
630
+ userDir: path.join(forgeqeHome(), "skills"),
631
+ dirs: list(s.dirs).map((d) => path.resolve(base, d)),
632
+ disable: list(s.disable),
633
+ external: s.external === true,
634
+ browserMaxChars: positiveInt(s.browser_max_chars, 6000, "skills.browser_max_chars"),
635
+ }
636
+ }
637
+
638
+ /** "90s", "15m", "1h30m", "1d" in milliseconds. */
639
+ export function parseDuration(raw: unknown, where: string): number {
640
+ const text = String(raw ?? "").trim().toLowerCase()
641
+ const parts = [...text.matchAll(/(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)/g)]
642
+ if (!text || !parts.length || parts.map((p) => p[0]).join("").replace(/\s/g, "") !== text.replace(/\s/g, "")) {
643
+ throw new Error(`${where} must be a duration such as 30s, 15m, 1h or 1h30m; got "${String(raw ?? "")}".`)
644
+ }
645
+ const unit = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 } as const
646
+ return parts.reduce((ms, p) => ms + Number(p[1]) * unit[p[2] as keyof typeof unit], 0)
647
+ }
648
+
649
+ function positiveInt(raw: unknown, fallback: number, where: string): number {
650
+ if (raw === undefined || raw === null) return fallback
651
+ const n = Number(raw)
652
+ if (!Number.isInteger(n) || n < 1) throw new Error(`${where} must be a whole number of at least 1; got "${String(raw)}".`)
653
+ return n
654
+ }
655
+
656
+ function monitorConfig(m: Record<string, unknown>, base: string, outDir: string): MonitorConfig {
657
+ const checks = Array.isArray(m.checks) ? m.checks.map(String).filter(Boolean) : m.checks ? [String(m.checks)] : []
658
+ const everyMs = parseDuration(m.every ?? "15m", "monitor.every")
659
+ if (everyMs < 10_000) throw new Error("monitor.every must be at least 10s.")
660
+ const remind = m.remind_every
661
+ return {
662
+ checks,
663
+ everyMs,
664
+ replay: replayMode(m.replay),
665
+ parallel: positiveInt(m.parallel, 1, "monitor.parallel"),
666
+ failAfter: positiveInt(m.fail_after, 1, "monitor.fail_after"),
667
+ remindMs: remind === undefined || remind === null || remind === 0 || remind === false || remind === "0" ? 0 : parseDuration(remind, "monitor.remind_every"),
668
+ keep: positiveInt(m.keep, 100, "monitor.keep"),
669
+ writeBack: m.write_back === true,
670
+ outDir: m.out_dir ? path.resolve(base, String(m.out_dir)) : path.join(outDir, "monitor"),
671
+ ...(m.report_url ? { reportUrl: trimUrl(m.report_url) } : {}),
672
+ alerts: monitorAlerts(asRecord(m.alerts)),
673
+ }
674
+ }
675
+
676
+ function monitorAlerts(a: Record<string, unknown>, where = "monitor.alerts"): MonitorAlerts {
677
+ const out: MonitorAlerts = {}
678
+ if (a.teams !== undefined && a.teams !== null && a.teams !== false) {
679
+ out.teams = { webhookEnv: String(asRecord(a.teams).webhook_env || "TEAMS_WEBHOOK_URL") }
680
+ }
681
+ if (a.email !== undefined && a.email !== null && a.email !== false) {
682
+ const e = asRecord(a.email)
683
+ const host = String(e.host || process.env.SMTP_HOST || "").trim()
684
+ if (!host) throw new Error(`${where}.email needs host (the SMTP server), or SMTP_HOST.`)
685
+ const to = (Array.isArray(e.to) ? e.to.map(String) : String(e.to || process.env.ALERT_EMAIL_TO || "").split(/[,;]/)).map((s) => s.trim()).filter(Boolean)
686
+ if (!to.length) throw new Error(`${where}.email needs to: the addresses alerts go to.`)
687
+ const port = Number(e.port || process.env.SMTP_PORT || 587)
688
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`${where}.email.port is not a port: ${String(e.port)}`)
689
+ out.email = {
690
+ host,
691
+ port,
692
+ secure: e.secure === undefined ? port === 465 : e.secure === true,
693
+ usernameEnv: String(e.username_env || "SMTP_USERNAME"),
694
+ passwordEnv: String(e.password_env || "SMTP_PASSWORD"),
695
+ ...(e.from ? { from: String(e.from) } : {}),
696
+ to,
697
+ }
698
+ }
699
+ return out
700
+ }
701
+
702
+ function screenshotsMode(raw: unknown): ReportConfig["screenshots"] {
703
+ const v = raw === false ? "off" : String(raw ?? "all").toLowerCase()
704
+ if (v === "all" || v === "failures" || v === "off") return v
705
+ throw new Error(`report.screenshots must be all, failures, or off; got "${String(raw)}".`)
706
+ }
707
+
708
+ /** Domain pattern for a login URL: scheme and host, without port or path, as the engine matches them. */
709
+ export function loginDomain(url: string): string {
710
+ const u = new URL(url)
711
+ return `${u.protocol}//${u.hostname}`
712
+ }
713
+
714
+ /** Re-read `logins:` from forgeqe.yaml so a login added while ForgeQE runs is found. */
715
+ export function reloadLogins(cfg: AppConfig): LoginConfig[] {
716
+ if (!cfg.configPath || !existsSync(cfg.configPath)) return cfg.logins
717
+ const sessionsDir = path.join(projectDir(path.dirname(cfg.configPath)), "sessions")
718
+ cfg.logins = loginsConfig(readYaml(cfg.configPath).logins, sessionsDir)
719
+ return cfg.logins
720
+ }
721
+
722
+ /** `logins:` in forgeqe.yaml, a map of names to { url | domains, username_env, password_env, totp_env, save_session }. */
723
+ function loginsConfig(raw: unknown, sessionsDir: string): LoginConfig[] {
724
+ if (raw === undefined || raw === null) return []
725
+ if (typeof raw !== "object" || Array.isArray(raw)) throw new Error("logins: in forgeqe.yaml must be a map of login names.")
726
+ return Object.entries(raw as Record<string, unknown>).map(([name, value]) => {
727
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) throw new Error(`Login name "${name}" may only use letters, digits, - and _.`)
728
+ const l = asRecord(value)
729
+ const url = l.url ? String(l.url) : undefined
730
+ let domains: string[]
731
+ try {
732
+ domains = Array.isArray(l.domains) ? l.domains.map(String).filter(Boolean) : url ? [loginDomain(url)] : []
733
+ } catch {
734
+ throw new Error(`logins.${name}.url is not a URL: ${url}`)
735
+ }
736
+ if (!domains.length) throw new Error(`logins.${name} needs a url (or domains) saying which site it signs in to.`)
737
+ const env = (k: string) => (l[k] ? String(l[k]) : undefined)
738
+ const login: LoginConfig = {
739
+ name,
740
+ domains,
741
+ ...(url ? { url } : {}),
742
+ ...(env("username_env") ? { usernameEnv: env("username_env") } : {}),
743
+ ...(env("password_env") ? { passwordEnv: env("password_env") } : {}),
744
+ ...(env("totp_env") ? { totpEnv: env("totp_env") } : {}),
745
+ ...(l.save_session === false ? {} : { sessionFile: path.join(sessionsDir, `${name}.json`) }),
746
+ }
747
+ if (!login.usernameEnv && !login.passwordEnv && !login.totpEnv && !login.sessionFile) {
748
+ throw new Error(`logins.${name} has nothing to use: set username_env, password_env, totp_env, or keep save_session on.`)
749
+ }
750
+ return login
751
+ })
752
+ }
753
+
754
+ function sourcesConfig(raw: Record<string, unknown>): SourcesConfig {
755
+ const ado = asRecord(raw.azure_devops)
756
+ const jira = asRecord(raw.jira)
757
+ const xray = asRecord(raw.xray)
758
+ const orgUrl = String(ado.org_url || process.env.ADO_ORG_URL || "").replace(/\/+$/, "")
759
+ const projectRaw = String(ado.project ?? process.env.ADO_PROJECT ?? "").trim()
760
+ const project = !projectRaw || projectRaw === "*" || /^all$/i.test(projectRaw) ? undefined : projectRaw
761
+ const jiraUrl = String(jira.url || process.env.JIRA_URL || "").replace(/\/+$/, "")
762
+ let cloud = false
763
+ if (jiraUrl) {
764
+ try {
765
+ cloud = jira.cloud === undefined ? new URL(jiraUrl).hostname.endsWith(".atlassian.net") : jira.cloud === true
766
+ } catch {
767
+ throw new Error(`sources.jira.url is not a URL: ${jiraUrl}`)
768
+ }
769
+ }
770
+ const domains = Array.isArray(ado.mcp_domains) ? ado.mcp_domains.map(String).filter(Boolean) : undefined
771
+ const jiraCfg = jiraUrl ? { url: jiraUrl, cloud, emailEnv: String(jira.email_env || "JIRA_EMAIL"), tokenEnv: String(jira.token_env || "JIRA_API_TOKEN") } : undefined
772
+ return {
773
+ writeBack: raw.write_back !== false,
774
+ saveCases: raw.save_cases !== false,
775
+ ado: orgUrl
776
+ ? { orgUrl, ...(project ? { project } : {}), tokenEnv: String(ado.token_env || "ADO_PAT"), mcp: ado.mcp !== false, ...(domains?.length ? { mcpDomains: domains } : {}) }
777
+ : undefined,
778
+ jira: jiraCfg ? { ...jiraCfg, mcp: jiraMcp(jira.mcp) } : undefined,
779
+ xray: {
780
+ url: String(xray.url || process.env.XRAY_URL || "https://xray.cloud.getxray.app").replace(/\/+$/, ""),
781
+ clientIdEnv: String(xray.client_id_env || "XRAY_CLIENT_ID"),
782
+ clientSecretEnv: String(xray.client_secret_env || "XRAY_CLIENT_SECRET"),
783
+ },
784
+ ...optional("confluence", confluenceConfig(raw.confluence, jiraCfg)),
785
+ ...optional("testrail", testrailConfig(asRecord(raw.testrail))),
786
+ ...optional("zephyr", zephyrConfig(raw.zephyr)),
787
+ ...optional("qtest", qtestConfig(asRecord(raw.qtest))),
788
+ ...optional("bugs", bugsConfig(raw.bugs)),
789
+ }
790
+ }
791
+
792
+ function flakyConfig(f: Record<string, unknown>): FlakyConfig {
793
+ const q = String(f.quarantine ?? "suggest")
794
+ return {
795
+ retries: f.retries === undefined ? 1 : Math.max(0, Math.min(3, Number(f.retries) || 0)),
796
+ quarantine: q === "auto" || q === "off" || q === "false" ? (q === "false" ? "off" : (q as "auto" | "off")) : "suggest",
797
+ flakyAfter: Number(f.flaky_after) > 0 ? Number(f.flaky_after) : 2,
798
+ }
799
+ }
800
+
801
+
802
+ /** Where edited prompts are looked for, highest precedence first: prompts.dir, the project's, then the user's. */
803
+ function promptDirsFor(p: Record<string, unknown>, base: string, configPath: string | undefined): { dir: string; source: string }[] {
804
+ const dirs = [
805
+ ...(p.dir ? [{ dir: path.resolve(base, String(p.dir)), source: "prompts.dir" }] : []),
806
+ ...(configPath ? [{ dir: path.join(projectDir(base), "prompts"), source: "project" }] : []),
807
+ { dir: path.join(forgeqeHome(), "prompts"), source: "user" },
808
+ ]
809
+ return dirs.filter((d, i) => dirs.findIndex((x) => path.resolve(x.dir) === path.resolve(d.dir)) === i)
810
+ }
811
+
812
+ function knowledgeConfig(k: Record<string, unknown>, base: string, configPath: string | undefined): NonNullable<AppConfig["knowledge"]> {
813
+ const dir = k.dir ? path.resolve(base, String(k.dir)) : configPath ? path.join(projectDir(base), "knowledge") : path.join(forgeqeHome(), "knowledge")
814
+ const max = Number(k.browser_max_chars)
815
+ return { dir, browserMaxChars: Number.isFinite(max) && max >= 0 && k.browser_max_chars !== undefined ? max : 1500 }
816
+ }
817
+
818
+ /** Visual baselines next to forgeqe.yaml (.forgeqe/visual-baselines, to commit), or under ~/.forgeqe without one. */
819
+ function withBaselineDir(q: QualityConfig, base: string, configPath: string | undefined): QualityConfig {
820
+ const dir = q.visual.baselineDir
821
+ ? path.resolve(base, q.visual.baselineDir)
822
+ : configPath
823
+ ? path.join(projectDir(base), "visual-baselines")
824
+ : path.join(forgeqeHome(), "visual-baselines")
825
+ return { ...q, visual: { ...q.visual, baselineDir: dir } }
826
+ }
827
+
828
+ function apiConfig(a: Record<string, unknown>): ApiConfig {
829
+ const headers = asRecord(a.headers)
830
+ return {
831
+ capture: a.capture !== false,
832
+ ...(a.base_url || a.baseUrl ? { baseUrl: trimUrl(a.base_url || a.baseUrl) } : {}),
833
+ headers: Object.fromEntries(Object.entries(headers).map(([k, v]) => [k, String(v ?? "")])),
834
+ thirdParty: a.third_party === true,
835
+ timeoutSeconds: Number(a.timeout_seconds) > 0 ? Number(a.timeout_seconds) : 30,
836
+ }
837
+ }
838
+
839
+ function optional<K extends string, V>(key: K, value: V | undefined): Partial<Record<K, V>> {
840
+ return value === undefined ? {} : ({ [key]: value } as Record<K, V>)
841
+ }
842
+
843
+ const trimUrl = (s: unknown) => String(s || "").trim().replace(/\/+$/, "")
844
+
845
+ function isCloud(url: string, explicit: unknown, what: string): boolean {
846
+ try {
847
+ return explicit === undefined ? new URL(url).hostname.endsWith(".atlassian.net") : explicit === true
848
+ } catch {
849
+ throw new Error(`${what} is not a URL: ${url}`)
850
+ }
851
+ }
852
+
853
+ /** `confluence: true` uses the Jira Cloud site's /wiki with the same account; a map sets url, email_env, token_env, space. */
854
+ function confluenceConfig(raw: unknown, jira?: { url: string; cloud: boolean; emailEnv: string; tokenEnv: string }): SourcesConfig["confluence"] {
855
+ if (raw === false) return undefined
856
+ const c = asRecord(raw)
857
+ const url = trimUrl(c.url || process.env.CONFLUENCE_URL || (raw === true && jira?.cloud ? `${jira.url}/wiki` : ""))
858
+ if (!url) {
859
+ if (raw === true) throw new Error("sources.confluence: true needs a Jira Cloud site in sources.jira; otherwise give confluence.url.")
860
+ return undefined
861
+ }
862
+ const cloud = isCloud(url, c.cloud, "sources.confluence.url")
863
+ const sameSite = Boolean(jira?.cloud && cloud && new URL(url).host === new URL(jira.url).host)
864
+ return {
865
+ url,
866
+ cloud,
867
+ emailEnv: String(c.email_env || (sameSite ? jira!.emailEnv : "CONFLUENCE_EMAIL")),
868
+ tokenEnv: String(c.token_env || (sameSite ? jira!.tokenEnv : "CONFLUENCE_TOKEN")),
869
+ ...(c.space ? { space: String(c.space) } : {}),
870
+ }
871
+ }
872
+
873
+ function testrailConfig(t: Record<string, unknown>): SourcesConfig["testrail"] {
874
+ const url = trimUrl(t.url || process.env.TESTRAIL_URL)
875
+ if (!url) return undefined
876
+ return {
877
+ url,
878
+ emailEnv: String(t.email_env || "TESTRAIL_EMAIL"),
879
+ tokenEnv: String(t.token_env || "TESTRAIL_API_KEY"),
880
+ ...(t.project_id ? { projectId: Number(t.project_id) } : {}),
881
+ }
882
+ }
883
+
884
+ function zephyrConfig(raw: unknown): SourcesConfig["zephyr"] {
885
+ if (raw === false) return undefined
886
+ const z = asRecord(raw)
887
+ const tokenEnv = String(z.token_env || "ZEPHYR_API_TOKEN")
888
+ if (raw === undefined && !process.env[tokenEnv]) return undefined
889
+ return {
890
+ url: trimUrl(z.url || process.env.ZEPHYR_URL || "https://api.zephyrscale.smartbear.com/v2"),
891
+ tokenEnv,
892
+ ...(z.project_key ? { projectKey: String(z.project_key) } : {}),
893
+ }
894
+ }
895
+
896
+ function qtestConfig(q: Record<string, unknown>): SourcesConfig["qtest"] {
897
+ const url = trimUrl(q.url || process.env.QTEST_URL)
898
+ if (!url) return undefined
899
+ const projectId = Number(q.project_id || process.env.QTEST_PROJECT_ID)
900
+ if (!projectId) throw new Error("sources.qtest needs project_id (the number in the project's URL), or QTEST_PROJECT_ID.")
901
+ const s = asRecord(q.statuses)
902
+ return {
903
+ url,
904
+ tokenEnv: String(q.token_env || "QTEST_TOKEN"),
905
+ projectId,
906
+ statuses: { passed: String(s.passed || "PASS"), failed: String(s.failed || "FAIL"), error: String(s.error || "BLOCKED") },
907
+ }
908
+ }
909
+
910
+ function bugsConfig(raw: unknown): BugsConfig | undefined {
911
+ if (raw === undefined || raw === null || raw === false) return undefined
912
+ const b = asRecord(raw)
913
+ const system = String(b.system || "").toLowerCase().replace(/[\s-]/g, "_")
914
+ if (system !== "jira" && system !== "azure_devops") throw new Error(`sources.bugs.system must be jira or azure_devops; got "${String(b.system ?? "")}".`)
915
+ if (system === "jira" && !b.project) throw new Error("sources.bugs.project is required for Jira: the project key bugs are filed in.")
916
+ return {
917
+ system,
918
+ ...(b.project ? { project: String(b.project) } : {}),
919
+ type: String(b.type || "Bug"),
920
+ labels: Array.isArray(b.labels) ? b.labels.map(String).filter(Boolean) : ["forgeqe"],
921
+ ...(b.area_path ? { areaPath: String(b.area_path) } : {}),
922
+ onError: b.on_error === true,
923
+ }
924
+ }
925
+
926
+ function jiraMcp(raw: unknown): JiraMcp | false {
927
+ if (raw === false || raw === "false" || raw === "off") return false
928
+ if (raw === undefined || raw === null || raw === true || raw === "mcp-atlassian") return "mcp-atlassian"
929
+ if (raw === "rovo") return "rovo"
930
+ throw new Error(`sources.jira.mcp must be mcp-atlassian, rovo, or false; got "${String(raw)}".`)
931
+ }
932
+
933
+ function stringMap(raw: unknown, where: string): Record<string, string> | undefined {
934
+ if (raw === undefined || raw === null) return undefined
935
+ if (typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${where} must be a map of names to values.`)
936
+ return Object.fromEntries(Object.entries(raw as Record<string, unknown>).map(([k, v]) => [k, String(v)]))
937
+ }
938
+
939
+ /** `mcp:` servers: `command` (a list, or a string split on spaces) with `args`, `env`, `cwd`; or `url` with `headers`. */
940
+ function extraMcpServers(raw: unknown, base: string): Record<string, ExtraMcpServer> | undefined {
941
+ if (raw === undefined || raw === null) return undefined
942
+ if (typeof raw !== "object" || Array.isArray(raw)) throw new Error("mcp: in forgeqe.yaml must be a map of server names.")
943
+ const out: Record<string, ExtraMcpServer> = {}
944
+ for (const [name, value] of Object.entries(raw as Record<string, unknown>)) {
945
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) throw new Error(`MCP server name "${name}" may only use letters, digits, - and _.`)
946
+ if (name === "forgeqe") throw new Error('The MCP server name "forgeqe" is reserved for ForgeQE\'s own tools.')
947
+ const s = asRecord(value)
948
+ const common = {
949
+ ...(s.timeout !== undefined ? { timeout: Number(s.timeout) } : {}),
950
+ ...(s.enabled === false ? { enabled: false } : {}),
951
+ }
952
+ if (s.url) {
953
+ const headers = stringMap(s.headers, `mcp.${name}.headers`)
954
+ out[name] = { type: "remote", url: String(s.url), ...(headers ? { headers } : {}), ...common }
955
+ } else if (s.command) {
956
+ const command = [...(Array.isArray(s.command) ? s.command.map(String) : String(s.command).split(/\s+/).filter(Boolean)), ...(Array.isArray(s.args) ? s.args.map(String) : [])]
957
+ const environment = stringMap(s.env ?? s.environment, `mcp.${name}.env`)
958
+ out[name] = { type: "local", command, ...(environment ? { environment } : {}), ...(s.cwd ? { cwd: path.resolve(base, String(s.cwd)) } : {}), ...common }
959
+ } else {
960
+ throw new Error(`mcp.${name} needs a command (local server) or a url (remote server).`)
961
+ }
962
+ }
963
+ return out
964
+ }
965
+
966
+ export function selectProfile(cfg: AppConfig, name: string): AppConfig {
967
+ const llm = cfg.profiles[name]
968
+ if (!llm) throw new Error(`Unknown LLM profile "${name}". Run: forgeqe models`)
969
+ return { ...cfg, activeProfile: name, llm }
970
+ }