@capagents/forgeqe 1.3.1 → 2.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 (119) hide show
  1. package/HOW_TO_USE.md +373 -9
  2. package/README.md +55 -4
  3. package/docs/ARCHITECTURE.md +62 -1
  4. package/docs/CONTAINERS.md +227 -0
  5. package/docs/COST_ARCHITECTURE.md +32 -0
  6. package/docs/DESKTOP-CI.md +87 -0
  7. package/docs/DESKTOP.md +387 -0
  8. package/docs/MOBILE.md +228 -0
  9. package/docs/feature-test-prompts.md +25 -0
  10. package/docs/security-data-flow.html +440 -3
  11. package/docs/user-guide.html +797 -415
  12. package/env.example.yaml +67 -0
  13. package/forgeqe.example.yaml +56 -1
  14. package/llms.example.json +7 -0
  15. package/package.json +4 -1
  16. package/prompts/brain.md +31 -0
  17. package/prompts/find-tests.md +1 -0
  18. package/py/forgeqe_desktop/__init__.py +1 -0
  19. package/py/forgeqe_desktop/__main__.py +3 -0
  20. package/py/forgeqe_desktop/apps.py +80 -0
  21. package/py/forgeqe_desktop/checks.py +168 -0
  22. package/py/forgeqe_desktop/cli.py +90 -0
  23. package/py/forgeqe_desktop/handhooks.py +293 -0
  24. package/py/forgeqe_desktop/handrecord.py +239 -0
  25. package/py/forgeqe_desktop/heal.py +107 -0
  26. package/py/forgeqe_desktop/models.py +208 -0
  27. package/py/forgeqe_desktop/pywinauto_driver.py +217 -0
  28. package/py/forgeqe_desktop/record.py +150 -0
  29. package/py/forgeqe_desktop/replay.py +345 -0
  30. package/py/forgeqe_desktop/service.py +356 -0
  31. package/py/forgeqe_desktop/session.py +60 -0
  32. package/py/forgeqe_desktop/ufo_bridge.py +86 -0
  33. package/py/forgeqe_desktop/ufo_runner.py +305 -0
  34. package/py/forgeqe_desktop/visual.py +112 -0
  35. package/py/forgeqe_desktop/workflow.py +338 -0
  36. package/py/forgeqe_engine/cli.py +10 -0
  37. package/py/forgeqe_engine/llm.py +54 -4
  38. package/py/forgeqe_engine/model_params.py +169 -0
  39. package/py/forgeqe_engine/summary.py +6 -0
  40. package/py/forgeqe_mobile/__init__.py +1 -0
  41. package/py/forgeqe_mobile/__main__.py +3 -0
  42. package/py/forgeqe_mobile/appium_driver.py +342 -0
  43. package/py/forgeqe_mobile/apps.py +84 -0
  44. package/py/forgeqe_mobile/cli.py +70 -0
  45. package/py/forgeqe_mobile/explore.py +203 -0
  46. package/py/forgeqe_mobile/hyperexecute.py +261 -0
  47. package/py/forgeqe_mobile/maestro.py +321 -0
  48. package/py/forgeqe_mobile/service.py +332 -0
  49. package/skills/testcontainers/SKILL.md +42 -0
  50. package/src/agents.ts +11 -2
  51. package/src/api/plan.ts +2 -0
  52. package/src/api/write.ts +2 -0
  53. package/src/auth/copilot.ts +130 -0
  54. package/src/auth/signin.ts +54 -1
  55. package/src/brain/index.ts +26 -4
  56. package/src/capabilities/guide.ts +2 -0
  57. package/src/capabilities/registry.ts +110 -2
  58. package/src/capabilities/status.ts +15 -0
  59. package/src/cases/defects.ts +2 -1
  60. package/src/cases/rca.ts +2 -0
  61. package/src/childEnv.ts +4 -1
  62. package/src/cli/args.ts +3 -2
  63. package/src/cli/help.ts +24 -3
  64. package/src/cli/index.ts +47 -3
  65. package/src/code/find.ts +26 -0
  66. package/src/code/loop.ts +4 -1
  67. package/src/code/prompt.ts +2 -1
  68. package/src/code/prreview.ts +2 -0
  69. package/src/code/review.ts +6 -2
  70. package/src/code/runtime.ts +34 -3
  71. package/src/commands.ts +6 -1
  72. package/src/config.ts +159 -3
  73. package/src/containers/command.ts +68 -0
  74. package/src/containers/compose.ts +173 -0
  75. package/src/containers/config.ts +283 -0
  76. package/src/containers/mock.ts +156 -0
  77. package/src/containers/run.ts +362 -0
  78. package/src/containers/tests.ts +80 -0
  79. package/src/context.ts +33 -6
  80. package/src/copilot.ts +155 -0
  81. package/src/desktop/bridge.ts +229 -0
  82. package/src/desktop/command.ts +350 -0
  83. package/src/desktop/edit.ts +217 -0
  84. package/src/desktop/junit.ts +35 -0
  85. package/src/desktop/report.ts +155 -0
  86. package/src/desktop/run.ts +252 -0
  87. package/src/desktop/setup.ts +134 -0
  88. package/src/engine/bridge.ts +12 -5
  89. package/src/engine/setup.ts +6 -3
  90. package/src/environments.ts +566 -0
  91. package/src/evals/domain.ts +1 -0
  92. package/src/explore/charters.ts +2 -0
  93. package/src/guardrails.ts +145 -0
  94. package/src/heal/loop.ts +4 -1
  95. package/src/heal/prompt.ts +2 -1
  96. package/src/init.ts +5 -2
  97. package/src/llm.ts +23 -2
  98. package/src/mcp/attachtools.ts +16 -4
  99. package/src/mcp/captools.ts +27 -0
  100. package/src/mcp/containertools.ts +212 -0
  101. package/src/mcp/desktoptools.ts +334 -0
  102. package/src/mcp/mobiletools.ts +132 -0
  103. package/src/mcp/reporttools.ts +17 -2
  104. package/src/mcp/repotests.ts +22 -3
  105. package/src/mcp/routing.ts +11 -5
  106. package/src/mcp/scope.ts +210 -0
  107. package/src/mcp/server.ts +80 -5
  108. package/src/mcp/tools.ts +38 -1
  109. package/src/mobile/command.ts +245 -0
  110. package/src/mobile/run.ts +226 -0
  111. package/src/pack/pipeline.ts +44 -2
  112. package/src/refine.ts +3 -0
  113. package/src/report/html.ts +3 -0
  114. package/src/report/template.html +48 -48
  115. package/src/run.ts +7 -3
  116. package/src/tokenomics/ledger.ts +3 -1
  117. package/src/tui/App.tsx +23 -0
  118. package/src/update.ts +118 -8
  119. package/src/usage.ts +1 -1
@@ -0,0 +1,566 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import YAML from "yaml"
5
+
6
+ import type { AppConfig, DesktopConfig, MobileConfig } from "./config.ts"
7
+
8
+ /**
9
+ * Test environments (env.yaml, named in forgeqe.yaml `environments.file`): each has the apps a scenario can use (web,
10
+ * Windows desktop, mobile), plain variables, and the names of the secrets its apps' workflows may type. One is active
11
+ * (`--env`, FORGEQE_ENV, or the default); ForgeQE's agent picks the apps a request needs from it. No secret values
12
+ * belong in env.yaml: only the names of environment variables that hold them.
13
+ */
14
+
15
+ export type AppKind = "web" | "desktop" | "mobile" | "api"
16
+
17
+ export type TestApp = {
18
+ name: string
19
+ kind: AppKind
20
+ /** What the app is for, in the team's words: how the agent knows which app a step of a scenario belongs to. */
21
+ description?: string
22
+ /** web: where the app starts; api: the base URL its calls go to. */
23
+ url?: string
24
+ /** desktop: an absolute .exe, its arguments, and a main-window title pattern for launchers. */
25
+ path?: string
26
+ args?: string[]
27
+ window?: string
28
+ /** mobile: the platform and the app's package or bundle id; an Android activity, and an app file or upload to install. */
29
+ platform?: "android" | "ios"
30
+ appId?: string
31
+ activity?: string
32
+ appFile?: string
33
+ /** A saved login (logins: in forgeqe.yaml) the app signs in with. */
34
+ login?: string
35
+ }
36
+
37
+ export type Environment = {
38
+ name: string
39
+ description?: string
40
+ apps: Record<string, TestApp>
41
+ /** The web app browser runs start at when no URL is given (start_app; else the only web app). */
42
+ startApp?: string
43
+ /** The API app API tests call (api_app; else the only API app). */
44
+ apiApp?: string
45
+ /** Plain values a scenario may use (a company name, a test customer); never secrets. */
46
+ variables: Record<string, string>
47
+ /** Environment variables this environment's workflows may type with {{secret:NAME}}. */
48
+ secrets: string[]
49
+ /**
50
+ * The environment as the team wrote it, when env.yaml is in its own layout rather than ForgeQE's: YAML with every
51
+ * secret value replaced, for the agents to read the names and values the team uses (a db_server, a tenant id).
52
+ */
53
+ details?: string
54
+ /** Secret values written into the file itself: never used or shown, and reported so they can be moved out. */
55
+ literalSecrets?: string[]
56
+ }
57
+
58
+ export type EnvironmentsConfig = {
59
+ file: string
60
+ /** The environment ForgeQE uses unless a request names another. */
61
+ active?: string
62
+ all: Record<string, Environment>
63
+ /** desktop: from forgeqe.yaml alone, before an environment's apps are added (switching environments starts from it). */
64
+ baseDesktop: DesktopConfig
65
+ /** mobile: from forgeqe.yaml alone, likewise. */
66
+ baseMobile?: MobileConfig
67
+ /** browser.allowed_hosts, browser.start_url and api.base_url from forgeqe.yaml alone, likewise. */
68
+ baseHosts: string[]
69
+ baseStartUrl?: string
70
+ baseApiUrl?: string
71
+ }
72
+
73
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/
74
+ const VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/
75
+
76
+ function record(value: unknown, where: string): Record<string, unknown> {
77
+ if (value === undefined || value === null) return {}
78
+ if (typeof value !== "object" || Array.isArray(value)) throw new Error(`${where} must be a mapping`)
79
+ return value as Record<string, unknown>
80
+ }
81
+
82
+ function text(value: unknown, where: string): string | undefined {
83
+ if (value === undefined || value === null || value === "") return undefined
84
+ if (typeof value !== "string" && typeof value !== "number") throw new Error(`${where} must be text`)
85
+ return String(value)
86
+ }
87
+
88
+ function webUrl(raw: string, where: string): string {
89
+ let url: URL
90
+ try {
91
+ url = new URL(raw)
92
+ } catch {
93
+ throw new Error(`${where} must be a full http(s) URL, e.g. https://qa.shop.example.com`)
94
+ }
95
+ if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error(`${where} must be an http(s) URL, not ${url.protocol}`)
96
+ if (url.username || url.password) throw new Error(`${where} must not carry a user name or password; use logins: in forgeqe.yaml`)
97
+ return url.href
98
+ }
99
+
100
+ function parseApp(name: string, raw: unknown, where: string): TestApp {
101
+ if (!NAME.test(name)) throw new Error(`${where}: app names are letters, digits and _ . -`)
102
+ const r = record(raw, where)
103
+ const kind = String(r.kind || "")
104
+ if (kind !== "web" && kind !== "desktop" && kind !== "mobile" && kind !== "api") throw new Error(`${where}.kind must be web, desktop, mobile or api`)
105
+ const app: TestApp = { name, kind, ...(text(r.description, `${where}.description`) ? { description: text(r.description, `${where}.description`) } : {}) }
106
+ const login = text(r.login, `${where}.login`)
107
+ if (login) app.login = login
108
+ if (kind === "web" || kind === "api") {
109
+ const url = text(r.url, `${where}.url`)
110
+ if (!url) throw new Error(`${where}.url is required for a${kind === "api" ? "n api" : " web"} app`)
111
+ app.url = webUrl(url, `${where}.url`)
112
+ } else if (kind === "desktop") {
113
+ const p = text(r.path, `${where}.path`)
114
+ if (!p) throw new Error(`${where}.path is required for a desktop app (an absolute path to its .exe)`)
115
+ if (!/^([A-Za-z]:[\\/]|\\\\|\/)/.test(p) || !/\.exe$/i.test(p) || /[\x00-\x1f]/.test(p)) {
116
+ throw new Error(`${where}.path must be an absolute path to an .exe; .bat, .cmd and scripts run through a shell`)
117
+ }
118
+ app.path = p
119
+ const args = r.args ?? []
120
+ if (!Array.isArray(args) || !args.every((a) => typeof a === "string" || typeof a === "number")) {
121
+ throw new Error(`${where}.args must be a list of arguments, not one command string`)
122
+ }
123
+ app.args = args.map(String)
124
+ const window = text(r.window, `${where}.window`)
125
+ if (window) app.window = window
126
+ } else {
127
+ const platform = String(r.platform || "")
128
+ if (platform !== "android" && platform !== "ios") throw new Error(`${where}.platform must be android or ios`)
129
+ const appId = text(r.app_id ?? r.appId, `${where}.app_id`)
130
+ if (!appId || !/^[A-Za-z0-9_.-]+$/.test(appId)) throw new Error(`${where}.app_id must be the app's package or bundle id`)
131
+ app.platform = platform
132
+ app.appId = appId
133
+ const activity = text(r.activity, `${where}.activity`)
134
+ if (activity) {
135
+ if (platform !== "android" || !/^[A-Za-z0-9_.$]{1,200}$/.test(activity)) throw new Error(`${where}.activity must be an Android activity name such as .MainActivity`)
136
+ app.activity = activity
137
+ }
138
+ const file = text(r.app ?? r.app_file, `${where}.app`)
139
+ if (file) {
140
+ if (/[\x00-\x1f]/.test(file) || !(/^https:\/\//i.test(file) || /^(bs|lt):\/\/[A-Za-z0-9._-]+$/.test(file) || /^storage:filename=[A-Za-z0-9._-]+$/.test(file) || (path.isAbsolute(file) && /\.(apk|aab|ipa|app|zip)$/i.test(file)))) {
141
+ throw new Error(`${where}.app must be an absolute path to an .apk, .aab, .ipa, .app or .zip, an https URL, or a device cloud upload (bs://…)`)
142
+ }
143
+ app.appFile = file
144
+ }
145
+ }
146
+ return app
147
+ }
148
+
149
+ function parseEnvironment(name: string, raw: unknown): Environment {
150
+ const where = `env.yaml environments.${name}`
151
+ if (!NAME.test(name)) throw new Error(`${where}: environment names are letters, digits and _ . -`)
152
+ const r = record(raw, where)
153
+ const apps: Record<string, TestApp> = {}
154
+ for (const [appName, appRaw] of Object.entries(record(r.apps, `${where}.apps`))) apps[appName] = parseApp(appName, appRaw, `${where}.apps.${appName}`)
155
+ const variables: Record<string, string> = {}
156
+ for (const [k, v] of Object.entries(record(r.variables, `${where}.variables`))) {
157
+ if (!VAR_NAME.test(k)) throw new Error(`${where}.variables: "${k}" must be letters, digits and _`)
158
+ const value = text(v, `${where}.variables.${k}`)
159
+ variables[k] = value ?? ""
160
+ }
161
+ const secrets = r.secrets ?? []
162
+ if (!Array.isArray(secrets) || !secrets.every((s) => typeof s === "string" && VAR_NAME.test(s))) {
163
+ throw new Error(`${where}.secrets must be a list of environment variable names (letters, digits and _); env.yaml holds no secret values`)
164
+ }
165
+ const pick = (key: string, kind: AppKind): string | undefined => {
166
+ const named = text(r[key], `${where}.${key}`)
167
+ if (named) {
168
+ if (apps[named]?.kind !== kind) throw new Error(`${where}.${key} must name one of its ${kind} apps`)
169
+ return named
170
+ }
171
+ const ofKind = Object.values(apps).filter((a) => a.kind === kind)
172
+ return ofKind.length === 1 ? ofKind[0]!.name : undefined
173
+ }
174
+ const startApp = pick("start_app", "web")
175
+ const apiApp = pick("api_app", "api")
176
+ return {
177
+ name,
178
+ ...(text(r.description, `${where}.description`) ? { description: text(r.description, `${where}.description`) } : {}),
179
+ apps,
180
+ ...(startApp ? { startApp } : {}),
181
+ ...(apiApp ? { apiApp } : {}),
182
+ variables,
183
+ secrets: [...new Set(secrets as string[])],
184
+ }
185
+ }
186
+
187
+
188
+ /** Keys whose values are secrets, in any layout: never shown to a model, never used as values. */
189
+ const SECRET_KEY = /(pass(word)?|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|credential|conn(ection)?[_-]?str(ing)?|sas|cookie|auth)/i
190
+ /** A secret written as a reference to an environment variable: ${NAME}, $NAME, env:NAME, %NAME%, {env:NAME}. */
191
+ const SECRET_REF = /^\s*(?:\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)|env:([A-Za-z_][A-Za-z0-9_]*)|%([A-Za-z_][A-Za-z0-9_]*)%|\{env:([A-Za-z_][A-Za-z0-9_]*)\})\s*$/
192
+ /** Inline credentials in a value: a connection string's Password=…, a URL's user:password@. */
193
+ const INLINE_SECRET = /(password|pwd|secret|token|accountkey|sharedaccesskey)\s*=\s*[^;]+|\/\/[^/\s:@]+:[^/\s@]+@/i
194
+ const URL_KEYS = /^(url|uri|base[_-]?url|baseurl|host|endpoint|address|link|base|site|web)$/i
195
+ const PATH_KEYS = /^(path|exe|executable|program|binary|app[_-]?path)$/i
196
+ const META_KEYS = new Set(["default", "default_env", "defaultenv", "default_environment", "defaultenvironment", "version", "common", "defaults", "shared", "global", "all"])
197
+
198
+ const safeName = (s: string) => s.replace(/[^A-Za-z0-9_.-]+/g, "_").replace(/^[^A-Za-z0-9]+/, "").slice(0, 64)
199
+ const safeVar = (s: string) => {
200
+ const v = s.replace(/[^A-Za-z0-9_]+/g, "_").replace(/^_+|_+$/g, "")
201
+ return /^[A-Za-z_]/.test(v) ? v.slice(0, 64) : `v_${v}`.slice(0, 64)
202
+ }
203
+
204
+ function deepMerge(a: Record<string, unknown>, b: Record<string, unknown>): Record<string, unknown> {
205
+ const out: Record<string, unknown> = { ...a }
206
+ for (const [k, v] of Object.entries(b)) {
207
+ const prev = out[k]
208
+ out[k] = prev && v && typeof prev === "object" && typeof v === "object" && !Array.isArray(prev) && !Array.isArray(v) ? deepMerge(prev as Record<string, unknown>, v as Record<string, unknown>) : v
209
+ }
210
+ return out
211
+ }
212
+
213
+ /** The mapping with every secret value replaced, for a model to read. */
214
+ function redact(value: unknown, key = ""): unknown {
215
+ if (value && typeof value === "object" && !Array.isArray(value)) return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, redact(v, k)]))
216
+ if (Array.isArray(value)) return value.map((v) => redact(v, key))
217
+ if (typeof value === "string") {
218
+ if (SECRET_KEY.test(key)) return SECRET_REF.test(value) ? value.trim() : "<secret, not shown>"
219
+ if (INLINE_SECRET.test(value)) return value.replace(INLINE_SECRET, (m) => (m.startsWith("//") ? "//<user>:<secret>@" : `${m.split("=")[0]}=<secret>`))
220
+ }
221
+ return value
222
+ }
223
+
224
+ /** An environment in the team's own layout: apps, variables and secret names found wherever they are. */
225
+ function parseFreeForm(name: string, r: Record<string, unknown>): Environment {
226
+ const apps: Record<string, TestApp> = {}
227
+ const variables: Record<string, string> = {}
228
+ const secrets = new Set<string>()
229
+ const literal: string[] = []
230
+ let primary: string | undefined
231
+ const addApp = (app: TestApp) => {
232
+ let n = safeName(app.name) || app.kind
233
+ for (let i = 2; apps[n]; i++) n = `${safeName(app.name) || app.kind}${i}`
234
+ apps[n] = { ...app, name: n }
235
+ return n
236
+ }
237
+ const walk = (node: unknown, trail: string[]) => {
238
+ if (node && typeof node === "object" && !Array.isArray(node)) {
239
+ const m = node as Record<string, unknown>
240
+ for (const [k, v] of Object.entries(m)) {
241
+ if (/^(description|desc)$/i.test(k) && trail.length === 0) continue
242
+ const at = [...trail, k]
243
+ if (typeof v === "string") {
244
+ const value = v.trim()
245
+ if (SECRET_KEY.test(k)) {
246
+ const ref = SECRET_REF.exec(value)
247
+ const refName = ref && (ref[1] || ref[2] || ref[3] || ref[4] || ref[5])
248
+ if (refName) secrets.add(refName)
249
+ else if (value) literal.push(at.join("."))
250
+ continue
251
+ }
252
+ if (INLINE_SECRET.test(value)) {
253
+ literal.push(at.join("."))
254
+ continue
255
+ }
256
+ // The app's name: the key, or for url/path-like keys the mapping it sits in.
257
+ const owner = (keys: RegExp, strip: RegExp) => (keys.test(k) ? trail[trail.length - 1] || "app" : k.replace(strip, "")) || k
258
+ if (/^https?:\/\//i.test(value)) {
259
+ try {
260
+ const url = webUrl(value, at.join("."))
261
+ const appName = owner(URL_KEYS, /[_-]?(base[_-]?)?(url|uri|endpoint|host)$/i)
262
+ const kind: AppKind = /api/i.test(at.join(".")) || /\/api(\/|$)/i.test(new URL(url).pathname) ? "api" : "web"
263
+ const desc = typeof m.description === "string" ? m.description : undefined
264
+ const added = addApp({ name: appName, kind, url, ...(URL_KEYS.test(k) && desc ? { description: desc } : {}) })
265
+ // A URL at the environment's top level (base_url, url) is its main web app: where browser runs start.
266
+ if (trail.length === 0 && URL_KEYS.test(k) && kind === "web" && !primary) primary = added
267
+ } catch {
268
+ // A URL with credentials or another scheme is not an app: it stays out of what the agents can open.
269
+ }
270
+ continue
271
+ }
272
+ if (/^([A-Za-z]:[\\/]|\\\\|\/).*\.exe$/i.test(value) && !/[\x00-\x1f]/.test(value)) {
273
+ const args = Array.isArray(m.args) ? m.args : Array.isArray(m.arguments) ? m.arguments : []
274
+ addApp({ name: owner(PATH_KEYS, /[_-]?(path|exe)$/i), kind: "desktop", path: value, args: args.filter((a) => typeof a === "string" || typeof a === "number").map(String), ...(typeof m.description === "string" ? { description: m.description } : {}) })
275
+ continue
276
+ }
277
+ if (value.length <= 500 && !/[\r\n]/.test(value)) variables[safeVar(at.join("_"))] = value
278
+ } else if (typeof v === "number" || typeof v === "boolean") {
279
+ if (!SECRET_KEY.test(k)) variables[safeVar(at.join("_"))] = String(v)
280
+ } else if (Array.isArray(v)) {
281
+ if (/^(args|arguments)$/i.test(k)) continue
282
+ if (/^secrets?$/i.test(k)) {
283
+ for (const s of v) {
284
+ if (typeof s !== "string" || !VAR_NAME.test(s)) throw new Error(`env.yaml ${name}: ${[...at].join(".")} must be a list of environment variable names (letters, digits and _); env.yaml holds no secret values`)
285
+ secrets.add(s)
286
+ }
287
+ continue
288
+ }
289
+ if (!SECRET_KEY.test(k) && v.every((x) => typeof x === "string" || typeof x === "number") && v.length <= 50) variables[safeVar(at.join("_"))] = v.join(", ")
290
+ } else if (v && typeof v === "object") walk(v, at)
291
+ }
292
+ }
293
+ }
294
+ walk(r, [])
295
+ const details = YAML.stringify(redact(r)).trim()
296
+ const env: Environment = {
297
+ name,
298
+ ...(typeof r.description === "string" ? { description: r.description } : {}),
299
+ apps,
300
+ variables,
301
+ secrets: [...secrets],
302
+ details: details.length > 6000 ? `${details.slice(0, 6000)}\n# …` : details,
303
+ ...(literal.length ? { literalSecrets: literal } : {}),
304
+ }
305
+ const web = Object.values(apps).filter((a) => a.kind === "web")
306
+ const api = Object.values(apps).filter((a) => a.kind === "api")
307
+ if (primary) env.startApp = primary
308
+ else if (web.length === 1) env.startApp = web[0]!.name
309
+ if (api.length === 1) env.apiApp = api[0]!.name
310
+ return env
311
+ }
312
+
313
+ /** ForgeQE's own layout: apps with a kind each (env.example.yaml). */
314
+ function isForgeqeLayout(r: Record<string, unknown>): boolean {
315
+ const apps = r.apps
316
+ return Boolean(apps && typeof apps === "object" && !Array.isArray(apps) && Object.values(apps).length && Object.values(apps).every((a) => a && typeof a === "object" && "kind" in (a as object)))
317
+ }
318
+
319
+ function anyEnvironment(name: string, raw: unknown, common: Record<string, unknown>): Environment {
320
+ if (!NAME.test(name)) throw new Error(`env.yaml: "${name}": environment names are letters, digits and _ . -`)
321
+ const r = deepMerge(common, record(raw, `env.yaml ${name}`))
322
+ return isForgeqeLayout(r) ? parseEnvironment(name, r) : parseFreeForm(name, r)
323
+ }
324
+
325
+ /**
326
+ * The environments in a document of any layout: `environments:` (or `envs:`), else every top-level mapping that is
327
+ * not a setting (default, common, …). A `common` / `defaults` / `shared` block is merged into each.
328
+ */
329
+ function environmentsOf(doc: Record<string, unknown>): Record<string, Environment> {
330
+ // A list of environments, each with its name: [{ name: qa, url: … }, …].
331
+ const list = [doc.environments, doc.envs].find((c) => Array.isArray(c)) as unknown[] | undefined
332
+ if (list) {
333
+ const out: Record<string, Environment> = {}
334
+ for (const item of list) {
335
+ const r = record(item, "env.yaml environments[]")
336
+ const name = String(r.name ?? r.env ?? r.id ?? "")
337
+ if (!name) throw new Error("env.yaml: each environment in the list needs a name")
338
+ const { name: _n, env: _e, id: _i, ...rest } = r
339
+ out[name] = anyEnvironment(name, rest, {})
340
+ }
341
+ return out
342
+ }
343
+ const container = [doc.environments, doc.envs, doc.environment].find((c) => c && typeof c === "object" && !Array.isArray(c)) as Record<string, unknown> | undefined
344
+ const from = container || doc
345
+ const commonRaw = [from.common, from.defaults, from.shared, from.global, doc.common, doc.defaults, doc.shared].find((c) => c && typeof c === "object" && !Array.isArray(c)) as Record<string, unknown> | undefined
346
+ const all: Record<string, Environment> = {}
347
+ for (const [name, raw] of Object.entries(from)) {
348
+ if (META_KEYS.has(name.toLowerCase()) || !raw || typeof raw !== "object" || Array.isArray(raw)) continue
349
+ if (!container && ["environments", "envs", "environment"].includes(name)) continue
350
+ all[name] = anyEnvironment(name, raw, commonRaw || {})
351
+ }
352
+ return all
353
+ }
354
+
355
+ const defaultOf = (doc: Record<string, unknown>) =>
356
+ [doc.default, doc.default_env, doc.defaultEnv, doc.default_environment, doc.defaultEnvironment].find((v) => typeof v === "string" && v) as string | undefined
357
+
358
+ /** env.yaml's environments, and which is active: `requested` (--env, FORGEQE_ENV), else forgeqe.yaml's default, else env.yaml's. */
359
+ export function loadEnvironments(
360
+ settings: Record<string, unknown>,
361
+ base: string,
362
+ baseDesktop: DesktopConfig,
363
+ requested?: string,
364
+ baseHosts: string[] = [],
365
+ base2: { startUrl?: string; apiUrl?: string; mobile?: MobileConfig } = {},
366
+ ): EnvironmentsConfig | undefined {
367
+ const fileSetting = settings.file === undefined ? undefined : String(settings.file || "")
368
+ const dirSetting = settings.dir === undefined ? undefined : String(settings.dir || "")
369
+ if (fileSetting === undefined && dirSetting === undefined && !requested) return undefined
370
+ let file: string
371
+ let all: Record<string, Environment> = {}
372
+ let docDefault: string | undefined
373
+ if (dirSetting !== undefined) {
374
+ // One file per environment (qa.yaml, uat.yaml): the file's name is the environment's.
375
+ file = path.resolve(base, dirSetting || ".")
376
+ if (!existsSync(file)) throw new Error(`environments.dir in forgeqe.yaml names ${file}, which does not exist.`)
377
+ for (const f of readdirSync(file).filter((n) => /\.(ya?ml|json)$/i.test(n)).sort()) {
378
+ const doc = YAML.parse(readFileSync(path.join(file, f), "utf8")) ?? {}
379
+ all[f.replace(/\.(ya?ml|json)$/i, "")] = anyEnvironment(f.replace(/\.(ya?ml|json)$/i, ""), doc, {})
380
+ }
381
+ } else {
382
+ file = path.resolve(base, fileSetting || "./env.yaml")
383
+ if (!existsSync(file)) {
384
+ if (fileSetting === undefined) throw new Error(`--env ${requested}: there is no env.yaml here. Add environments: { file: ./env.yaml } to forgeqe.yaml.`)
385
+ throw new Error(`environments.file in forgeqe.yaml names ${file}, which does not exist. Run forgeqe init to create one.`)
386
+ }
387
+ const doc = record(YAML.parse(readFileSync(file, "utf8")) ?? {}, "env.yaml")
388
+ all = environmentsOf(doc)
389
+ docDefault = defaultOf(doc)
390
+ }
391
+ const names = Object.keys(all)
392
+ const fallback = text(settings.default, "environments.default") || docDefault || (names.length === 1 ? names[0] : undefined)
393
+ const active = requested || fallback
394
+ if (active && !all[active]) throw new Error(`No environment "${active}" in ${file} (environments: ${names.join(", ") || "none"}).`)
395
+ return { file, ...(active ? { active } : {}), all, baseDesktop, ...(base2.mobile ? { baseMobile: base2.mobile } : {}), baseHosts, ...(base2.startUrl ? { baseStartUrl: base2.startUrl } : {}), ...(base2.apiUrl ? { baseApiUrl: base2.apiUrl } : {}) }
396
+ }
397
+
398
+ /** The desktop apps of an environment, in the form desktop.apps takes (the engine checks each entry again). */
399
+ function desktopApps(env: Environment): Record<string, unknown> {
400
+ const out: Record<string, unknown> = {}
401
+ for (const app of Object.values(env.apps)) {
402
+ if (app.kind === "desktop") out[app.name] = { path: app.path, args: app.args || [], ...(app.window ? { window: app.window } : {}) }
403
+ }
404
+ return out
405
+ }
406
+
407
+ /** The mobile apps of an environment, in the form mobile.apps takes (the engine checks each entry again). */
408
+ function mobileApps(env: Environment): Record<string, unknown> {
409
+ const out: Record<string, unknown> = {}
410
+ for (const app of Object.values(env.apps)) {
411
+ if (app.kind === "mobile") out[app.name] = { platform: app.platform, app_id: app.appId, ...(app.activity ? { activity: app.activity } : {}), ...(app.appFile ? { app: app.appFile } : {}) }
412
+ }
413
+ return out
414
+ }
415
+
416
+ /**
417
+ * The config with `name`'s apps and secrets in effect: its desktop apps join desktop.apps (an environment's entry wins
418
+ * over forgeqe.yaml's of the same name), its mobile apps join mobile.apps, and its secrets join desktop.secrets and
419
+ * mobile.secrets. The config is not changed.
420
+ */
421
+ export function withEnvironment(cfg: AppConfig, name?: string): AppConfig {
422
+ const envs = cfg.environments
423
+ if (!envs) {
424
+ if (name) throw new Error(`No environments are configured, so "${name}" cannot be used. Add environments: { file: ./env.yaml } to forgeqe.yaml.`)
425
+ return cfg
426
+ }
427
+ const chosen = name || envs.active
428
+ if (!chosen) return cfg
429
+ const env = envs.all[chosen]
430
+ if (!env) throw new Error(`No environment "${chosen}" in ${envs.file} (environments: ${Object.keys(envs.all).join(", ") || "none"}).`)
431
+ const base = envs.baseDesktop
432
+ // An allow-list of hosts (browser.allowed_hosts) also lets in this environment's web apps; an empty one allows any.
433
+ const webHosts = Object.values(env.apps).flatMap((a) => ((a.kind === "web" || a.kind === "api") && a.url ? [new URL(a.url).hostname] : []))
434
+ const hosts = envs.baseHosts.length ? [...new Set([...envs.baseHosts, ...webHosts])] : envs.baseHosts
435
+ // The environment's start app and API app win over forgeqe.yaml's start_url and api.base_url: they are what differs
436
+ // between environments. Without them, forgeqe.yaml's stay.
437
+ const startUrl = env.startApp ? env.apps[env.startApp]!.url : envs.baseStartUrl
438
+ const apiUrl = env.apiApp ? env.apps[env.apiApp]!.url : envs.baseApiUrl
439
+ return {
440
+ ...cfg,
441
+ browser: { ...cfg.browser, allowedHosts: hosts, startUrl },
442
+ ...(cfg.api ? { api: { ...cfg.api, baseUrl: apiUrl } } : {}),
443
+ environments: { ...envs, active: chosen },
444
+ desktop: { ...base, apps: { ...base.apps, ...desktopApps(env) }, secrets: [...new Set([...base.secrets, ...env.secrets])] },
445
+ ...(envs.baseMobile
446
+ ? { mobile: { ...envs.baseMobile, apps: { ...envs.baseMobile.apps, ...mobileApps(env) }, secrets: [...new Set([...envs.baseMobile.secrets, ...env.secrets])] } }
447
+ : {}),
448
+ }
449
+ }
450
+
451
+ export function activeEnvironment(cfg: AppConfig): Environment | undefined {
452
+ const envs = cfg.environments
453
+ return envs?.active ? envs.all[envs.active] : undefined
454
+ }
455
+
456
+ /** A web app's URL in the active (or named) environment; throws for an unknown app or one that is not a web app. */
457
+ export function webAppUrl(cfg: AppConfig, app: string, env?: string): string {
458
+ const c = withEnvironment(cfg, env)
459
+ const e = activeEnvironment(c)
460
+ const found = e?.apps[app]
461
+ if (!found) throw new Error(`No app "${app}" in ${e ? `environment ${e.name}` : "env.yaml (no environment is active)"}${e ? ` (apps: ${Object.keys(e.apps).join(", ") || "none"})` : ""}.`)
462
+ if (found.kind !== "web" || !found.url) throw new Error(`App "${app}" is a ${found.kind} app, not a web app: use the ${found.kind} tools for it.`)
463
+ return found.url
464
+ }
465
+
466
+ /** An app name as part of an environment variable name: SHOP for shop, ORDER_API for order-api. */
467
+ const envKey = (name: string) => name.toUpperCase().replace(/[^A-Z0-9]+/g, "_")
468
+
469
+ /**
470
+ * What tests and other programs ForgeQE runs read the environment from: FORGEQE_ACTIVE_ENV, FORGEQE_APP_<APP>_URL (web
471
+ * and API apps), FORGEQE_APP_<APP>_PATH (desktop apps), FORGEQE_VAR_<NAME> (variables). Never secrets: those stay in
472
+ * the variables env.yaml names.
473
+ */
474
+ export function environmentVariables(cfg: AppConfig): Record<string, string> {
475
+ const env = activeEnvironment(cfg)
476
+ if (!env) return {}
477
+ const out: Record<string, string> = { FORGEQE_ACTIVE_ENV: env.name }
478
+ for (const a of Object.values(env.apps)) {
479
+ if (a.url) out[`FORGEQE_APP_${envKey(a.name)}_URL`] = a.url
480
+ if (a.path) out[`FORGEQE_APP_${envKey(a.name)}_PATH`] = a.path
481
+ if (a.appId) out[`FORGEQE_APP_${envKey(a.name)}_ID`] = a.appId
482
+ }
483
+ for (const [k, v] of Object.entries(env.variables)) out[`FORGEQE_VAR_${envKey(k)}`] = v
484
+ // A line break would let a value set another variable where these are written line by line (CI env files).
485
+ for (const [k, v] of Object.entries(out)) if (/[\r\n]/.test(v)) delete out[k]
486
+ return out
487
+ }
488
+
489
+ const EXPORTED = /^FORGEQE_(ACTIVE_ENV$|APP_|VAR_)/
490
+
491
+ /** Put the active environment's variables into this process's environment, so every test command and agent sees them. */
492
+ export function exportEnvironment(cfg: AppConfig, env: Record<string, string | undefined> = process.env): void {
493
+ for (const k of Object.keys(env)) if (EXPORTED.test(k)) delete env[k]
494
+ Object.assign(env, environmentVariables(cfg))
495
+ }
496
+
497
+ /**
498
+ * The environment as one agent needs it. agent: ForgeQE's agent, which picks apps for requests and scenarios. browser:
499
+ * the browser agent, which must stay on this environment's hosts. code: Scribe, the healer and the reviewer, which
500
+ * write and fix tests that must run in every environment.
501
+ */
502
+ export function environmentBrief(cfg: AppConfig, audience: "agent" | "browser" | "code"): string {
503
+ const env = activeEnvironment(cfg)
504
+ if (!env) return ""
505
+ const apps = Object.values(env.apps)
506
+ const appLine = (a: TestApp) => {
507
+ const where = a.url || a.path || (a.appId ? `${a.platform} ${a.appId}` : "")
508
+ return `- ${a.name} (${a.kind}${where ? `: ${where}` : ""})${a.description ? `: ${a.description}` : ""}`
509
+ }
510
+ const values = Object.entries(env.variables)
511
+ if (audience === "browser") {
512
+ const web = apps.filter((a) => a.kind === "web" || a.kind === "api")
513
+ if (!web.length && !values.length) return ""
514
+ return [
515
+ `Test environment: ${env.name}${env.description ? ` (${env.description})` : ""}.`,
516
+ ...(web.length ? ["Its web apps, where a goal that names one of them means:", ...web.map(appLine), "Stay on this environment's hosts; never use another environment's URL for these apps."] : []),
517
+ ...(values.length ? ["Values of this environment, for goals that refer to them:", ...values.map(([k, v]) => `- ${k}: ${v}`)] : []),
518
+ ].join("\n")
519
+ }
520
+ if (audience === "code") {
521
+ const vars = environmentVariables(cfg)
522
+ const urls = Object.entries(vars).filter(([k]) => /_URL$/.test(k))
523
+ return [
524
+ `The tests run against test environment ${env.name}; others are ${Object.keys(cfg.environments!.all).filter((n) => n !== env.name).join(", ") || "none yet"}. A test must run unchanged in every environment:`,
525
+ "- Never write an environment's URL, host or app path into a test. Read it where the repository already keeps its base URL or settings; if it keeps none, read these variables, which ForgeQE sets whenever it runs the tests, with the repository's own default as fallback:",
526
+ ...urls.map(([k, v]) => ` ${k} (now ${v})`),
527
+ "- FORGEQE_ACTIVE_ENV names the environment, and FORGEQE_VAR_<NAME> holds its values (" + (values.map(([k]) => k).join(", ") || "none") + "). Secrets are never in these variables.",
528
+ ].join("\n")
529
+ }
530
+ const others = Object.values(cfg.environments!.all).filter((e) => e.name !== env.name)
531
+ return [
532
+ `Test environment in use: ${env.name}${env.description ? ` (${env.description})` : ""}, from ${cfg.environments!.file}.${others.length ? ` Others: ${others.map((e) => e.name).join(", ")} (use_environment switches; tools also take env for one call).` : ""}`,
533
+ apps.length ? ["Its apps (pick by description; never invent a URL or path):", ...apps.map(appLine)].join("\n") : "It has no apps yet.",
534
+ env.details
535
+ ? `The environment as the team wrote it (secrets replaced); use its names and values when a request refers to them:\n\`\`\`yaml\n${env.details}\n\`\`\``
536
+ : values.length
537
+ ? `Its values: ${values.map(([k, v]) => `${k}=${v}`).join(", ")}.`
538
+ : "",
539
+ env.secrets.length ? `Secrets its workflows may use, by name only: ${env.secrets.join(", ")}.` : "",
540
+ env.literalSecrets?.length ? `Secret values are written in the file at ${env.literalSecrets.join(", ")}: they are not used or shown; tell the user to move them to environment variables.` : "",
541
+ env.startApp ? `Browser runs start at ${env.startApp} when no app or URL is given.` : "",
542
+ ]
543
+ .filter(Boolean)
544
+ .join("\n")
545
+ }
546
+
547
+ /** `forgeqe apps`: the environments, and the apps of the active (or named) one. */
548
+ export function appsText(cfg: AppConfig, env?: string): string {
549
+ if (!cfg.environments) return "No environments are configured. Run forgeqe init (it writes env.yaml), or add environments: { file: ./env.yaml } to forgeqe.yaml."
550
+ const c = withEnvironment(cfg, env)
551
+ const active = activeEnvironment(c)
552
+ const lines: string[] = [`environments (${c.environments!.file})`]
553
+ for (const e of Object.values(c.environments!.all)) lines.push(`${e.name === active?.name ? "*" : " "} ${e.name.padEnd(12)} ${e.description || ""}`.trimEnd())
554
+ if (!active) return [...lines, "", "No environment is active: pass --env NAME, set FORGEQE_ENV, or set default in env.yaml."].join("\n")
555
+ lines.push("", `apps in ${active.name}`)
556
+ for (const a of Object.values(active.apps)) {
557
+ const where = a.url ? a.url : a.path ? `${a.path}${a.args?.length ? ` ${JSON.stringify(a.args)}` : ""}` : `${a.platform} ${a.appId}`
558
+ lines.push(` ${a.name.padEnd(14)} ${a.kind.padEnd(8)} ${where}${a.description ? `\n ${"".padEnd(14)} ${"".padEnd(8)} ${a.description}` : ""}`)
559
+ }
560
+ if (!Object.keys(active.apps).length) lines.push(" (none)")
561
+ const vars = Object.entries(active.variables)
562
+ if (vars.length) lines.push("", "variables", ...vars.map(([k, v]) => ` ${k} = ${v}`))
563
+ if (active.literalSecrets?.length) lines.push("", `! secret values written in the file (not used; move them to environment variables): ${active.literalSecrets.join(", ")}`)
564
+ lines.push("", `secrets ${c.desktop.secrets.join(", ") || "none"}`)
565
+ return lines.join("\n")
566
+ }
@@ -238,6 +238,7 @@ export async function runDomainEval(opts: EvalOptions): Promise<EvalReport> {
238
238
  profile: judgeConfig.llm,
239
239
  // The judge is given the domain as data and must not apply the domain rule itself.
240
240
  domain: null,
241
+ guardrails: null,
241
242
  role: "judge",
242
243
  model: judgeConfig.code.model,
243
244
  bin: judgeConfig.code.opencode,
@@ -6,6 +6,7 @@ import type { Story } from "./story.ts"
6
6
  import { domainNotes } from "../domain.ts"
7
7
  import { knowledgeBlock, readKnowledge, relevantKnowledge } from "../knowledge.ts"
8
8
  import { prompt } from "../prompts.ts"
9
+ import { guardrailsNotes } from "../guardrails.ts"
9
10
 
10
11
  /**
11
12
  * Exploratory charters: what to try on a story's feature beyond its scripted test cases. Each charter has a mission (the
@@ -113,6 +114,7 @@ export async function planCharters(opts: {
113
114
  repo: opts.cwd,
114
115
  profile: opts.config.llm,
115
116
  domain: domainNotes(opts.config),
117
+ guardrails: guardrailsNotes(opts.config),
116
118
  model: opts.config.code.model,
117
119
  bin: opts.config.code.opencode,
118
120
  readDirs: [opts.cwd],