@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,334 @@
1
+ import type { DesktopEvent, DesktopResult } from "../desktop/bridge.ts"
2
+ import { mkdirSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ import { engineValidate, proposeEdit, writeEdit } from "../desktop/edit.ts"
6
+ import { allowedWorkflow, checkApp, checkDesktopConfig, checkVariables, desktopOutDir, exploreGoal, hasModel, privateDesktopSecrets, recordByHand, recordingNotes, replayWorkflow, visionProblem } from "../desktop/run.ts"
7
+ import { NOT_WINDOWS, desktopDoctor } from "../desktop/setup.ts"
8
+ import { activeEnvironment, environmentBrief, exportEnvironment, withEnvironment } from "../environments.ts"
9
+ import type { McpTool, ToolContext } from "./tools.ts"
10
+
11
+ /** The apps of the test environments, and Windows thick-client apps (docs/DESKTOP.md): explore a goal, replay a saved workflow. */
12
+
13
+ const clip = (s: string | undefined, n: number) => {
14
+ const t = (s || "").trim()
15
+ return t.length <= n ? t : `${t.slice(0, n - 1)}…`
16
+ }
17
+
18
+ function onWindows(platform: string = process.platform): string | null {
19
+ return platform === "win32" ? null : NOT_WINDOWS
20
+ }
21
+
22
+ export function stepEvents(ctx: ToolContext): (e: DesktopEvent) => void {
23
+ return (e) => {
24
+ if (e.type === "step") ctx.emit?.({ type: "info", text: `${e.ok ? "✓" : "✗"} ${e.index} ${e.op}${e.healed_target ? " (healed)" : ""}${e.error ? `: ${clip(e.error, 200)}` : ""}`, ok: e.ok })
25
+ else if (e.type === "log" && e.text && e.level !== "debug") ctx.emit?.({ type: "info", text: clip(e.text, 300), ...(e.level === "error" ? { ok: false } : {}) })
26
+ }
27
+ }
28
+
29
+ /** Ask the user before the desktop's mouse and keyboard are taken over; where ForgeQE cannot ask, the caller confirms. */
30
+ export async function confirmed(ctx: ToolContext, args: Record<string, any>, title: string, detail: string): Promise<{ go: true } | { go: false; answer: Record<string, unknown> }> {
31
+ if (ctx.ask) {
32
+ const choice = await ctx.ask({
33
+ title,
34
+ detail,
35
+ options: [
36
+ { id: "start", label: "Start" },
37
+ { id: "no", label: "Don't start" },
38
+ ],
39
+ })
40
+ return choice === "start" ? { go: true } : { go: false, answer: { ran: false, reason: "The user chose not to start it." } }
41
+ }
42
+ if (args.confirmed === true) return { go: true }
43
+ return {
44
+ go: false,
45
+ answer: {
46
+ ran: false,
47
+ needs_confirmation: true,
48
+ what: `${title} ${detail}`,
49
+ next_step: "Tell the user this takes over the desktop's mouse and keyboard, and call again with confirmed: true only after they agree.",
50
+ },
51
+ }
52
+ }
53
+
54
+ export function stepSummary(r: DesktopResult) {
55
+ return (r.steps || []).map((s) => ({ index: s.index, op: s.op, ok: s.ok, ...(s.error ? { error: clip(s.error, 300) } : {}), ...(s.healed_target ? { healed_to: s.healed_target } : {}) }))
56
+ }
57
+
58
+ export const DESKTOP_TOOLS: McpTool[] = [
59
+ {
60
+ name: "test_apps",
61
+ description:
62
+ "The test environments (env.yaml) and the apps in one: web apps with their URL, Windows desktop apps, and mobile apps, each with what it is for, plus the environment's variables and the secrets its workflows may type, and which apps can run on this machine. Call it first for any request that names an app or an environment, and to plan a scenario that moves between apps (e.g. order on the web shop, invoice in the desktop billing app): pick the apps by their descriptions, never invent a URL or path.",
63
+ inputSchema: {
64
+ type: "object",
65
+ properties: { env: { type: "string", description: "An environment by name (default: the active one)" } },
66
+ },
67
+ async run(args, ctx) {
68
+ const cfg = withEnvironment(ctx.config, args.env ? String(args.env) : undefined)
69
+ const env = activeEnvironment(cfg)
70
+ const windows = process.platform === "win32"
71
+ const doc = windows ? desktopDoctor() : undefined
72
+ const apps = env
73
+ ? Object.values(env.apps).map((a) => ({
74
+ name: a.name,
75
+ kind: a.kind,
76
+ ...(a.description ? { description: a.description } : {}),
77
+ ...(a.url ? { url: a.url } : {}),
78
+ ...(a.path ? { path: a.path } : {}),
79
+ ...(a.platform ? { platform: a.platform, app_id: a.appId } : {}),
80
+ ...(a.login ? { login: a.login } : {}),
81
+ usable_here: a.kind === "desktop" ? windows : true,
82
+ ...(a.kind === "desktop" && !windows ? { why_not: NOT_WINDOWS } : {}),
83
+ tool:
84
+ a.kind === "web"
85
+ ? "browser_run with app"
86
+ : a.kind === "desktop"
87
+ ? "desktop_explore / desktop_record / desktop_replay with app"
88
+ : a.kind === "mobile"
89
+ ? "mobile_explore / mobile_replay with app (Appium or Maestro); mobile_maestro for the team's Maestro flows"
90
+ : "API tests (run_api_scenarios, run_api_contracts)",
91
+ }))
92
+ : []
93
+ // desktop.apps in forgeqe.yaml itself, outside any environment.
94
+ const extraDesktop = Object.keys(cfg.desktop.apps).filter((n) => !env?.apps[n])
95
+ return {
96
+ environment: env ? { name: env.name, ...(env.description ? { description: env.description } : {}) } : null,
97
+ environments: cfg.environments ? Object.values(cfg.environments.all).map((e) => ({ name: e.name, ...(e.description ? { description: e.description } : {}), active: e.name === env?.name })) : [],
98
+ apps,
99
+ ...(extraDesktop.length ? { other_desktop_apps: extraDesktop } : {}),
100
+ variables: env?.variables || {},
101
+ secrets: cfg.desktop.secrets,
102
+ ...(cfg.mobile.secrets.length ? { mobile_secrets: cfg.mobile.secrets } : {}),
103
+ refused_secrets: privateDesktopSecrets(cfg),
104
+ windows,
105
+ desktop_engine_ready: Boolean(doc?.ready),
106
+ desktop_explore_ready: Boolean(doc?.ufo),
107
+ ...(!cfg.environments ? { next_step: "No environments are configured: tell the user to run forgeqe init (it writes env.yaml) and list their apps there." } : apps.length || extraDesktop.length ? {} : { next_step: `Environment ${env?.name || ""} has no apps: tell the user to add them to ${cfg.environments.file}.` }),
108
+ }
109
+ },
110
+ },
111
+ {
112
+ name: "use_environment",
113
+ description:
114
+ "Switch the test environment (env.yaml) for the rest of this session: every later browser run, test case run, API test, desktop run, and the tests ForgeQE runs (FORGEQE_APP_*_URL) use it. Use when the user says to work in another environment (\"now in UAT\"); for a single call, pass env to that tool instead.",
115
+ inputSchema: {
116
+ type: "object",
117
+ properties: { env: { type: "string", description: "The environment's name, from test_apps" } },
118
+ required: ["env"],
119
+ },
120
+ line: (args) => `environment ${String(args.env || "")}`,
121
+ async run(args, ctx) {
122
+ const name = String(args.env || "").trim()
123
+ if (!name) throw new Error("env is required.")
124
+ const switched = withEnvironment(ctx.config, name)
125
+ // The session shares this config object: later tool calls see the new environment.
126
+ Object.assign(ctx.config, switched)
127
+ exportEnvironment(ctx.config)
128
+ process.env.FORGEQE_ENV = name
129
+ ctx.emit?.({ type: "info", text: `Environment: ${name}`, ok: true })
130
+ return { environment: name, brief: environmentBrief(ctx.config, "agent") }
131
+ },
132
+ },
133
+ {
134
+ name: "desktop_explore",
135
+ description:
136
+ "Carry out a goal in a Windows desktop app (from test_apps) with UFO² on this machine's desktop, and save it as a replayable workflow with checks of the outcome. Asks the user first. Returns the workflow, steps, checks, values shown at the end (for the next app of a scenario), and the report. Windows only; needs a vision model.",
137
+ inputSchema: {
138
+ type: "object",
139
+ properties: {
140
+ goal: { type: "string", description: "What to do in the app, in the user's words" },
141
+ app: { type: "string", description: "A desktop app's name, from test_apps" },
142
+ name: { type: "string", description: "A name for the workflow" },
143
+ env: { type: "string", description: "The test environment (default: the active one)" },
144
+ confirmed: { type: "boolean", description: "Only where ForgeQE cannot ask: the user agreed" },
145
+ },
146
+ required: ["goal", "app"],
147
+ },
148
+ line: (args) => `desktop explore ${String(args.app || "")}: ${clip(String(args.goal || ""), 80)}`,
149
+ async run(args, ctx) {
150
+ const notHere = onWindows()
151
+ if (notHere) return { ran: false, reason: notHere }
152
+ const goal = String(args.goal || "").trim()
153
+ const app = String(args.app || "").trim()
154
+ if (!goal || !app) throw new Error("goal and app are required.")
155
+ const cfg = withEnvironment(ctx.config, args.env ? String(args.env) : undefined)
156
+ checkDesktopConfig(cfg, true)
157
+ checkApp(cfg, app)
158
+ const problem = visionProblem(cfg.activeProfile, cfg.llm)
159
+ if (problem) return { ran: false, reason: problem }
160
+ const ok = await confirmed(ctx, args, `UFO² will control this desktop's mouse and keyboard in ${app} to: ${clip(goal, 200)}.`, "It acts in that app's windows only (no shell commands). Use a test machine and a test account.")
161
+ if (!ok.go) return ok.answer
162
+ const outDir = desktopOutDir(cfg, "explore")
163
+ const { result, report } = await exploreGoal({ cfg, goal, app, name: args.name ? String(args.name) : undefined, outDir, signal: ctx.signal, onEvent: stepEvents(ctx), log: (t) => ctx.emit?.({ type: "info", text: t }) })
164
+ return {
165
+ ran: true,
166
+ ok: result.ok,
167
+ workflow: result.workflow || undefined,
168
+ recorded_steps: result.recorded ?? 0,
169
+ checks: result.checks ?? 0,
170
+ values: result.values && Object.keys(result.values).length ? result.values : undefined,
171
+ not_recorded: result.skipped?.length ? result.skipped : undefined,
172
+ refused: result.refused?.length ? result.refused.map((r) => r.action) : undefined,
173
+ ufo_judged_done: result.ufo_complete ?? undefined,
174
+ error: result.error || undefined,
175
+ report,
176
+ next_step: result.workflow
177
+ ? `Offer desktop_replay with workflow ${result.workflow} to check it runs without a model.${(result.checks ?? 0) === 0 ? " It has no checks of the outcome: say so, and suggest adding expect steps." : ""}`
178
+ : "Say what UFO did and did not do; suggest a clearer goal or checking the app's controls with Inspect.exe.",
179
+ }
180
+ },
181
+ },
182
+ {
183
+ name: "desktop_replay",
184
+ description:
185
+ "Replay a desktop workflow in its Windows app with no model, checking its expect steps. A moved control is healed with one model call and reported, never written. Asks the user first. Returns each step's result, heals, and the report. Windows only.",
186
+ inputSchema: {
187
+ type: "object",
188
+ properties: {
189
+ workflow: { type: "string", description: "The workflow file, in the working or reports folder" },
190
+ variables: { type: "object", additionalProperties: { type: "string" }, description: "Values for its {{name}} variables" },
191
+ heal: { type: "boolean", description: "Heal a moved control (default: desktop.heal)" },
192
+ env: { type: "string", description: "The test environment (default: the active one)" },
193
+ confirmed: { type: "boolean", description: "Only where ForgeQE cannot ask: the user agreed" },
194
+ },
195
+ required: ["workflow"],
196
+ },
197
+ line: (args) => `desktop replay ${String(args.workflow || "")}`,
198
+ async run(args, ctx) {
199
+ const notHere = onWindows()
200
+ if (notHere) return { ran: false, reason: notHere }
201
+ const cfg = withEnvironment(ctx.config, args.env ? String(args.env) : undefined)
202
+ checkDesktopConfig(cfg, false)
203
+ const workflow = allowedWorkflow(String(args.workflow || ""), [ctx.cwd, cfg.export.outDir])
204
+ const variables = checkVariables(args.variables)
205
+ const ok = await confirmed(ctx, args, `ForgeQE will replay ${workflow} on this desktop's mouse and keyboard.`, "It starts the workflow's app from forgeqe.yaml and acts in its windows only.")
206
+ if (!ok.go) return ok.answer
207
+ const heal = (args.heal === undefined ? cfg.desktop.heal : args.heal === true) && hasModel(cfg)
208
+ const outDir = desktopOutDir(cfg, "replay")
209
+ const { result, report } = await replayWorkflow({ cfg, workflow, variables, heal, outDir, signal: ctx.signal, onEvent: stepEvents(ctx), log: (t) => ctx.emit?.({ type: "info", text: t }) })
210
+ return {
211
+ ran: true,
212
+ ok: result.ok,
213
+ stopped: result.stopped || undefined,
214
+ steps: stepSummary(result),
215
+ total: result.total,
216
+ heals: result.heals?.length ? result.heals : undefined,
217
+ error: result.error || undefined,
218
+ report,
219
+ next_step: result.heals?.length
220
+ ? `Tell the user which locators were healed; they can write them into the workflow with: forgeqe desktop replay "${workflow}" --accept-heals`
221
+ : result.ok
222
+ ? undefined
223
+ : "Say which step failed and why, from its error and the report's screenshot.",
224
+ }
225
+ },
226
+ },
227
+ {
228
+ name: "desktop_record",
229
+ description:
230
+ "Record a desktop workflow by hand, with no model: ForgeQE starts the Windows app and saves the user's clicks, typing and keys as workflow.yaml. Ctrl+Shift+F11 over a control adds a check of its text; Ctrl+Shift+F12 finishes. Passwords are saved as {{secret:NAME}}. Asks the user first. Windows only.",
231
+ inputSchema: {
232
+ type: "object",
233
+ properties: {
234
+ app: { type: "string", description: "A desktop app's name, from test_apps" },
235
+ name: { type: "string", description: "A name for the workflow" },
236
+ env: { type: "string", description: "The test environment (default: the active one)" },
237
+ confirmed: { type: "boolean", description: "Only where ForgeQE cannot ask: the user agreed" },
238
+ },
239
+ required: ["app"],
240
+ },
241
+ line: (args) => `desktop record ${String(args.app || "")}`,
242
+ async run(args, ctx) {
243
+ const notHere = onWindows()
244
+ if (notHere) return { ran: false, reason: notHere }
245
+ const app = String(args.app || "").trim()
246
+ if (!app) throw new Error("app is required.")
247
+ const cfg = withEnvironment(ctx.config, args.env ? String(args.env) : undefined)
248
+ checkDesktopConfig(cfg, false)
249
+ checkApp(cfg, app)
250
+ const ok = await confirmed(
251
+ ctx,
252
+ args,
253
+ `ForgeQE will start ${app} and record what you do in it.`,
254
+ "Do the steps yourself. Ctrl+Shift+F11 over a control adds a check of what it shows; Ctrl+Shift+F12 or closing the app finishes. Only clicks and keys in the app's own windows are recorded; password fields are saved as secret names, never as what you type.",
255
+ )
256
+ if (!ok.go) return ok.answer
257
+ const outDir = desktopOutDir(cfg, "record")
258
+ const onEvent = (e: DesktopEvent) => {
259
+ if (e.type === "recorded") ctx.emit?.({ type: "info", text: `● ${e.index} ${e.op}${e.keys ? ` ${e.keys}` : ""}${e.target ? ` ${clip(String(e.target.name || e.target.automation_id || ""), 60)}` : ""}` })
260
+ else stepEvents(ctx)(e)
261
+ }
262
+ const { result, report } = await recordByHand({ cfg, app, name: args.name ? String(args.name) : undefined, outDir, signal: ctx.signal, onEvent, log: (t) => ctx.emit?.({ type: "info", text: t }) })
263
+ const notes = recordingNotes(cfg, result)
264
+ return {
265
+ ran: true,
266
+ ok: result.ok,
267
+ workflow: result.workflow || undefined,
268
+ recorded_steps: result.recorded ?? 0,
269
+ checks: result.checks ?? 0,
270
+ steps: (result.steps || []).map((s) => ({ index: s.index, op: s.op, ...(s.target ? { target: s.target } : {}) })),
271
+ not_recorded: result.skipped?.length ? result.skipped : undefined,
272
+ secrets: result.secrets?.length ? result.secrets : undefined,
273
+ stopped_by: result.stopped_by || undefined,
274
+ notes: notes.length ? notes : undefined,
275
+ error: result.error || undefined,
276
+ report,
277
+ next_step: result.workflow ? `Show the steps, pass on the notes, and offer desktop_replay with workflow ${result.workflow}.` : "Say nothing was recorded, and why (the error), and offer to start again.",
278
+ }
279
+ },
280
+ },
281
+ {
282
+ name: "desktop_edit_workflow",
283
+ description:
284
+ "Change a desktop or mobile workflow after a failed replay or on request: timeout, retries, a wait_for step, a dialog to dismiss, a healed target, a text, steps, variables. Step numbers are the replay report's. Validated; the user sees the diff and is asked before it is written.",
285
+ inputSchema: {
286
+ type: "object",
287
+ properties: {
288
+ workflow: { type: "string", description: "The workflow file, in the working or reports folder" },
289
+ reason: { type: "string", description: "Why, in one sentence for the user" },
290
+ changes: {
291
+ type: "array",
292
+ description:
293
+ 'In order: {op:"set"|"unset", field:"timeout"|"retries", value?, step?} (no step: the workflow); {op:"set_step", step, field, value} (field: target, text, value, keys, seconds, direction, gone, image, tolerance, ignore, note, timeout, retries); {op:"add_dismiss", target}; {op:"insert_step", before|after, step}; {op:"remove_step", step}; {op:"set_variable", name, value}.',
294
+ items: { type: "object" },
295
+ },
296
+ confirmed: { type: "boolean", description: "Only where ForgeQE cannot ask: the user agreed to the diff" },
297
+ },
298
+ required: ["workflow", "changes"],
299
+ },
300
+ line: (args) => `desktop edit ${String(args.workflow || "")}`,
301
+ async run(args, ctx) {
302
+ const cfg = ctx.config
303
+ const workflow = allowedWorkflow(String(args.workflow || ""), [ctx.cwd, cfg.export.outDir])
304
+ let edit
305
+ try {
306
+ edit = await proposeEdit(workflow, args.changes, ctx.validateDesktopWorkflow || engineValidate)
307
+ } catch (e) {
308
+ return { written: false, error: (e as Error).message, next_step: "Fix the changes (the error says which) and call again; nothing was written." }
309
+ }
310
+ if (edit.before === edit.after) return { written: false, reason: "The changes leave the workflow as it is." }
311
+ const reason = clip(String(args.reason || ""), 300)
312
+ const shown = { workflow, changes: edit.summary, diff: edit.diff }
313
+ if (ctx.ask) {
314
+ const choice = await ctx.ask({
315
+ title: `Change ${path.basename(workflow)}?`,
316
+ detail: reason || undefined,
317
+ lines: [...edit.summary.map((l) => `• ${l}`), "", ...edit.diff.slice(0, 80)],
318
+ options: [
319
+ { id: "write", label: "Yes, write the change" },
320
+ { id: "no", label: "No, leave the workflow as it is" },
321
+ ],
322
+ })
323
+ if (choice !== "write") return { ...shown, written: false, reason: "The user chose not to change the workflow." }
324
+ } else if (args.confirmed !== true) {
325
+ return { ...shown, written: false, needs_confirmation: true, next_step: "Show the user this diff, and call again with the same changes and confirmed: true only after they agree." }
326
+ }
327
+ const backupDir = path.join(cfg.export.outDir, "desktop-edits")
328
+ mkdirSync(backupDir, { recursive: true })
329
+ const backup = writeEdit(workflow, edit, backupDir)
330
+ ctx.emit?.({ type: "info", text: `Updated ${workflow}`, ok: true })
331
+ return { ...shown, written: true, backup, next_step: `Offer desktop_replay with workflow ${workflow} to check the fix.` }
332
+ },
333
+ },
334
+ ]
@@ -0,0 +1,132 @@
1
+ import { allowedWorkflow, checkVariables, visionProblem } from "../desktop/run.ts"
2
+ import { withEnvironment } from "../environments.ts"
3
+ import { checkMobileApp, checkMobileConfig, exploreMobile, hasModel, maestroFlowFiles, mobileOutDir, replayMobile, runMaestroFlows } from "../mobile/run.ts"
4
+ import { confirmed, stepEvents, stepSummary } from "./desktoptools.ts"
5
+ import type { McpTool } from "./tools.ts"
6
+
7
+ /** Android and iOS apps (docs/MOBILE.md): explore a goal through Appium, replay a workflow on Appium or Maestro, run the team's Maestro flows. */
8
+
9
+ const clip = (s: string | undefined, n: number) => {
10
+ const t = (s || "").trim()
11
+ return t.length <= n ? t : `${t.slice(0, n - 1)}…`
12
+ }
13
+
14
+ export const MOBILE_TOOLS: McpTool[] = [
15
+ {
16
+ name: "mobile_explore",
17
+ description:
18
+ "Carry out a goal in an Android or iOS app (from test_apps) through Appium, and save it as a replayable workflow with checks of the outcome. Asks the user first. Returns the workflow, steps, checks, values shown at the end (for the next app of a scenario), and the report. Needs Appium and a vision model.",
19
+ inputSchema: {
20
+ type: "object",
21
+ properties: {
22
+ goal: { type: "string", description: "What to do in the app, in the user's words" },
23
+ app: { type: "string", description: "A mobile app's name, from test_apps" },
24
+ name: { type: "string", description: "A name for the workflow" },
25
+ env: { type: "string", description: "The test environment (default: the active one)" },
26
+ confirmed: { type: "boolean", description: "Only where ForgeQE cannot ask: the user agreed" },
27
+ },
28
+ required: ["goal", "app"],
29
+ },
30
+ line: (args) => `mobile explore ${String(args.app || "")}: ${clip(String(args.goal || ""), 80)}`,
31
+ async run(args, ctx) {
32
+ const goal = String(args.goal || "").trim()
33
+ const app = String(args.app || "").trim()
34
+ if (!goal || !app) throw new Error("goal and app are required.")
35
+ const cfg = withEnvironment(ctx.config, args.env ? String(args.env) : undefined)
36
+ checkMobileConfig(cfg)
37
+ checkMobileApp(cfg, app)
38
+ if (!hasModel(cfg)) return { ran: false, reason: "No model is set up in llms.json; explore needs one. Replay and Maestro flows need none." }
39
+ const problem = visionProblem(cfg.activeProfile, cfg.llm)
40
+ if (problem) return { ran: false, reason: problem.replace("the desktop agent", "the mobile agent") }
41
+ const ok = await confirmed(ctx, args, `The mobile agent will operate ${app} on the device to: ${clip(goal, 200)}.`, `Through Appium at ${cfg.mobile.appium.url}. Use a test device and a test account.`)
42
+ if (!ok.go) return ok.answer
43
+ const outDir = mobileOutDir(cfg, "explore")
44
+ const { result, report } = await exploreMobile({ cfg, goal, app, name: args.name ? String(args.name) : undefined, outDir, signal: ctx.signal, onEvent: stepEvents(ctx), log: (t) => ctx.emit?.({ type: "info", text: t }) })
45
+ return {
46
+ ran: true,
47
+ ok: result.ok,
48
+ workflow: result.workflow || undefined,
49
+ recorded_steps: result.recorded ?? 0,
50
+ checks: result.checks ?? 0,
51
+ values: result.values && Object.keys(result.values).length ? result.values : undefined,
52
+ error: result.error || undefined,
53
+ report,
54
+ next_step: result.workflow
55
+ ? `Offer mobile_replay with workflow ${result.workflow} (driver appium or maestro).${(result.checks ?? 0) === 0 ? " It has no checks of the outcome: say so." : ""}`
56
+ : "Say what was and was not done; suggest a clearer goal.",
57
+ }
58
+ },
59
+ },
60
+ {
61
+ name: "mobile_replay",
62
+ description:
63
+ "Replay a mobile workflow (forgeqe-mobile-v1) with no model: on Appium step by step (a moved control is healed and reported, never written), or on Maestro. Asks the user first. Returns each step's result and the report.",
64
+ inputSchema: {
65
+ type: "object",
66
+ properties: {
67
+ workflow: { type: "string", description: "The workflow file, in the working or reports folder" },
68
+ variables: { type: "object", additionalProperties: { type: "string" }, description: "Values for its {{name}} variables" },
69
+ driver: { type: "string", enum: ["appium", "maestro"], description: "Default: mobile.driver" },
70
+ heal: { type: "boolean", description: "Appium: heal a moved control (default: mobile.heal)" },
71
+ env: { type: "string", description: "The test environment (default: the active one)" },
72
+ confirmed: { type: "boolean", description: "Only where ForgeQE cannot ask: the user agreed" },
73
+ },
74
+ required: ["workflow"],
75
+ },
76
+ line: (args) => `mobile replay ${String(args.workflow || "")}`,
77
+ async run(args, ctx) {
78
+ const cfg = withEnvironment(ctx.config, args.env ? String(args.env) : undefined)
79
+ checkMobileConfig(cfg)
80
+ const workflow = allowedWorkflow(String(args.workflow || ""), [ctx.cwd, cfg.export.outDir])
81
+ const variables = checkVariables(args.variables)
82
+ const driver = args.driver === undefined ? cfg.mobile.driver : args.driver
83
+ if (driver !== "appium" && driver !== "maestro") throw new Error("driver must be appium or maestro.")
84
+ const ok = await confirmed(ctx, args, `ForgeQE will replay ${workflow} on the device with ${driver}.`, "It starts the workflow's app from the environment or forgeqe.yaml mobile.apps and acts in it only.")
85
+ if (!ok.go) return ok.answer
86
+ const heal = args.heal === undefined ? cfg.mobile.heal : args.heal === true
87
+ const outDir = mobileOutDir(cfg, "replay")
88
+ const { result, report } = await replayMobile({ cfg, workflow, variables, heal, driver, outDir, signal: ctx.signal, onEvent: stepEvents(ctx), log: (t) => ctx.emit?.({ type: "info", text: t }) })
89
+ return {
90
+ ran: true,
91
+ ok: result.ok,
92
+ driver,
93
+ steps: stepSummary(result),
94
+ total: result.total,
95
+ heals: result.heals?.length ? result.heals : undefined,
96
+ error: result.error || undefined,
97
+ report,
98
+ next_step: result.heals?.length
99
+ ? "Tell the user which locators were healed; desktop_edit_workflow (set_step target) writes them into the workflow when they agree."
100
+ : result.ok
101
+ ? undefined
102
+ : "Say which step failed and why. When the app was slow or a dialog was in the way, fix the workflow with desktop_edit_workflow (it edits mobile workflows too).",
103
+ }
104
+ },
105
+ },
106
+ {
107
+ name: "mobile_maestro",
108
+ description:
109
+ "Run the team's own Maestro flows (files or folders in the working folder). Each is checked first: only UI commands pass (no scripts, JavaScript, AI commands or file writes). Asks the user first. Returns each flow's result.",
110
+ inputSchema: {
111
+ type: "object",
112
+ properties: {
113
+ flows: { type: "array", items: { type: "string" }, description: "Flow files or folders" },
114
+ env: { type: "string", description: "The test environment (default: the active one)" },
115
+ confirmed: { type: "boolean", description: "Only where ForgeQE cannot ask: the user agreed" },
116
+ },
117
+ required: ["flows"],
118
+ },
119
+ line: (args) => `maestro ${Array.isArray(args.flows) ? args.flows.join(" ") : ""}`,
120
+ async run(args, ctx) {
121
+ const cfg = withEnvironment(ctx.config, args.env ? String(args.env) : undefined)
122
+ checkMobileConfig(cfg)
123
+ if (!Array.isArray(args.flows) || !args.flows.length || !args.flows.every((f: unknown) => typeof f === "string")) throw new Error("flows must be a list of files or folders.")
124
+ const flows = maestroFlowFiles(args.flows as string[], ctx.cwd)
125
+ const ok = await confirmed(ctx, args, `ForgeQE will run ${flows.length} Maestro flow${flows.length === 1 ? "" : "s"} on the device.`, "Each is checked against the allowlist first.")
126
+ if (!ok.go) return ok.answer
127
+ const outDir = mobileOutDir(cfg, "maestro")
128
+ const { result, report } = await runMaestroFlows({ cfg, flows, root: ctx.cwd, outDir, signal: ctx.signal, onEvent: stepEvents(ctx), log: (t) => ctx.emit?.({ type: "info", text: t }) })
129
+ return { ran: true, ok: result.ok, flows: result.flows || [], checked: result.checked?.length, error: result.error || undefined, report }
130
+ },
131
+ },
132
+ ]
@@ -84,9 +84,24 @@ ${body}
84
84
  `
85
85
  }
86
86
 
87
- /** The summary as HTML, with raw HTML in it shown as text so a report can never run script. */
87
+ /** The summary as HTML, with raw HTML in it shown as text and only web links kept, so a report can never run script. */
88
88
  export function summaryHtml(markdown: string): string {
89
- return Bun.markdown.html(markdown.replace(/<(?=[A-Za-z/!?])/g, "&lt;"))
89
+ const html = Bun.markdown.html(markdown.replace(/<(?=[A-Za-z/!?])/g, "&lt;"))
90
+ return html.replace(/\s(href|src)="([^"]*)"/g, (whole, _attr: string, value: string) => (linkAllowed(value) ? whole : ""))
91
+ }
92
+
93
+ const codePoint = (n: number) => (n <= 0x10ffff ? String.fromCodePoint(n) : "")
94
+
95
+ /** A link a report may keep: http(s), mailto, or one with no scheme (an anchor or a relative path). */
96
+ function linkAllowed(attr: string): boolean {
97
+ const url = attr
98
+ .replace(/&#x([0-9a-f]+);?/gi, (_, h: string) => codePoint(parseInt(h, 16)))
99
+ .replace(/&#(\d+);?/g, (_, d: string) => codePoint(Number(d)))
100
+ .replace(/&quot;/g, '"')
101
+ .replace(/&amp;/g, "&")
102
+ .replace(/[\u0000-\u0020\u007f]/g, "")
103
+ const scheme = /^([^/?#]*?):/.exec(url)?.[1]
104
+ return !scheme || /^(?:https?|mailto)$/i.test(scheme)
90
105
  }
91
106
 
92
107
  const MAX_ROWS = 5000
@@ -5,6 +5,10 @@ import { privateEnvNames } from "../childEnv.ts"
5
5
  import { findSprint, listSprints, recentSprints, sprintLabel, type Sprint } from "../cases/sprints.ts"
6
6
  import { findTests, loadSelection, resultsCard, runSelection, selectionCard, type TestRange } from "../code/find.ts"
7
7
  import { CAUSE_TEXT, flakyReport, matchTest, RELEASE_AFTER, setQuarantine } from "../code/history.ts"
8
+ import { containersOf, startOrder } from "../containers/config.ts"
9
+ import { ensureServices, findRuntime } from "../containers/run.ts"
10
+ import { containerTestRunner } from "../containers/tests.ts"
11
+ import { containerExec } from "./containertools.ts"
8
12
  import type { McpTool, ToolContext } from "./tools.ts"
9
13
 
10
14
  const clip = (s: string | undefined, n: number) => {
@@ -144,6 +148,7 @@ export const REPO_TEST_TOOLS: McpTool[] = [
144
148
  selection_id: { type: "string", description: "From find_tests" },
145
149
  confirmed: { type: "boolean", description: "Only where ForgeQE cannot ask: the user agreed to run this list" },
146
150
  timeout_seconds: { type: "number", description: "Stop the run after this many seconds (default: code.test_timeout_seconds)" },
151
+ in_container: { type: "boolean", description: "Run them in containers.tests' image (Docker or Podman), with its services started first. Default: true when forgeqe.yaml sets containers.tests" },
147
152
  },
148
153
  required: ["selection_id"],
149
154
  },
@@ -151,10 +156,16 @@ export const REPO_TEST_TOOLS: McpTool[] = [
151
156
  const sel = loadSelection(ctx.config, String(args.selection_id))
152
157
  const n = sel.tests.length
153
158
  if (!n) return { ran: false, reason: "The selection has no tests." }
159
+ const tc = containersOf(ctx.config).tests
160
+ const inContainer = args.in_container === undefined ? Boolean(tc) : args.in_container === true
161
+ if (inContainer && !tc) return { ran: false, reason: "containers.tests is not set in forgeqe.yaml: it names the image the tests run in (docs/CONTAINERS.md)." }
162
+ const runtime = inContainer ? findRuntime(ctx.config) : null
163
+ if (inContainer && !runtime) return { ran: false, reason: "Docker or Podman is not on PATH, so the tests cannot run in a container. Run them on this machine with in_container: false, or install one." }
164
+ const where = inContainer && tc ? ` in a container (${tc.image}${tc.services.length ? `, with ${tc.services.join(", ")}` : ""})` : ""
154
165
  const what = `${n} test${n === 1 ? "" : "s"}`
155
166
  if (ctx.ask) {
156
167
  const choice = await ctx.ask({
157
- title: `Run ${what}?`,
168
+ title: `Run ${what}${where}?`,
158
169
  detail: sel.runCommand,
159
170
  lines: [`Selected for "${sel.request}":`, ...sel.tests.slice(0, 20).map((t) => ` • ${t.name}`), ...(n > 20 ? [` and ${n - 20} more`] : [])],
160
171
  options: [
@@ -169,12 +180,20 @@ export const REPO_TEST_TOOLS: McpTool[] = [
169
180
  needs_confirmation: true,
170
181
  tests: sel.tests.map((t) => t.name),
171
182
  run_command: sel.runCommand,
183
+ ...(where ? { where: where.trim() } : {}),
172
184
  next_step: "Show the user this list and ask whether to run it; call run_tests again with confirmed: true only after they say yes.",
173
185
  }
174
186
  }
175
- ctx.emit?.({ type: "agent", event: { type: "code", kind: "info", text: `ForgeQE runs ${what}: ${sel.runCommand}` } })
187
+ ctx.emit?.({ type: "agent", event: { type: "code", kind: "info", text: `ForgeQE runs ${what}${where}: ${sel.runCommand}` } })
176
188
  const timeout = Number(args.timeout_seconds) > 0 ? Number(args.timeout_seconds) : ctx.config.code.testTimeoutSeconds
177
- const r = await runSelection({ selection: sel, timeoutSeconds: timeout, signal: ctx.signal, runTest: ctx.runTest, hide: privateEnvNames(ctx.config), flaky: ctx.config.flaky })
189
+ let runTest = ctx.runTest
190
+ if (inContainer && tc && runtime) {
191
+ const rt = { cfg: ctx.config, runtime, exec: containerExec(), ...(ctx.signal ? { signal: ctx.signal } : {}), log: (text: string) => ctx.emit?.({ type: "info" as const, text }) }
192
+ const svc = await ensureServices(rt, startOrder(containersOf(ctx.config).services, tc.services))
193
+ if (svc.failed) return { ran: false, reason: `${svc.failed.service} did not start: ${svc.failed.error}`, logs: svc.failed.logs }
194
+ runTest = containerTestRunner(rt, sel.repo, [sel.dir])
195
+ }
196
+ const r = await runSelection({ selection: sel, timeoutSeconds: timeout, signal: ctx.signal, runTest, hide: privateEnvNames(ctx.config), flaky: ctx.config.flaky })
178
197
  ctx.emit?.({ type: "card", card: resultsCard(r, ctx.cwd) })
179
198
  const failed = r.tests.filter((t) => t.status === "failed")
180
199
  return {
@@ -1,14 +1,16 @@
1
1
  import { CAPABILITIES, type Capability } from "../capabilities/registry.ts"
2
2
  import { prompt } from "../prompts.ts"
3
+ import type { CapabilityScope } from "./scope.ts"
3
4
  import type { McpTool } from "./tools.ts"
4
5
 
5
6
  /**
6
7
  * What ForgeQE's agent carries on every model call, and what it gets only when a task needs it.
7
8
  *
8
- * Every model call sends the system prompt and the tool definitions again. Tools stay listed in full: their
9
- * descriptions are how the agent chooses a tool and plans the task, and their arguments are needed for any call, so a
10
- * tool listed in one line cost an extra model call each time (measured: more tokens, not fewer). The playbook is
11
- * what moves: the sections of brain.md that a capability owns (`playbook`) leave the system prompt, an index of them
9
+ * Every model call sends the system prompt and the tool definitions again. A tool the agent has stays listed in full:
10
+ * its description is how the agent chooses a tool and plans the task, and its arguments are needed for any call, so a
11
+ * tool listed in one line cost an extra model call each time (measured: more tokens, not fewer). Which tools a turn
12
+ * has is the scope's job (src/mcp/scope.ts): the core capabilities and the ones the request is about. The playbook is
13
+ * what moves within them: the sections of brain.md that a capability owns (`playbook`) leave the system prompt, an index of them
12
14
  * stays, and the first result of one of the capability's tools in a session (or of a tool in `alsoAfter`) carries the
13
15
  * section. The `playbook` tool reads one before that. A section no capability owns stays in the system prompt, so a
14
16
  * section renamed in a team's own copy of brain.md is never lost.
@@ -84,7 +86,7 @@ export const brainParts = (): BrainParts => splitBrain(prompt("brain"))
84
86
  * Routing for one agent session: which playbooks it was given, and what goes with a result. The playbook text is read
85
87
  * once, when the session starts, like the system prompt.
86
88
  */
87
- export function createRouter(tools: McpTool[], opts: { caps?: Capability[]; brain?: string } = {}) {
89
+ export function createRouter(tools: McpTool[], opts: { caps?: Capability[]; brain?: string; scope?: CapabilityScope } = {}) {
88
90
  const caps = opts.caps || CAPABILITIES
89
91
  const parts = splitBrain(opts.brain ?? prompt("brain"), caps)
90
92
  const given = new Set<string>()
@@ -100,8 +102,11 @@ export function createRouter(tools: McpTool[], opts: { caps?: Capability[]; brai
100
102
 
101
103
  return {
102
104
  parts,
105
+ /** Which tools each turn of this session gets (src/mcp/scope.ts); absent when every tool is always given. */
106
+ scope: opts.scope,
103
107
  /** What goes after an error of a tool: the playbooks of its capability this session has not had yet. */
104
108
  after(name: string): string {
109
+ opts.scope?.used(name)
105
110
  return block(give(playbooksFor(name)))
106
111
  },
107
112
  /**
@@ -109,6 +114,7 @@ export function createRouter(tools: McpTool[], opts: { caps?: Capability[]; brai
109
114
  * whatever reads it; appended to any other text.
110
115
  */
111
116
  attach(name: string, text: string): string {
117
+ opts.scope?.used(name)
112
118
  const books = give(playbooksFor(name))
113
119
  if (!books.length) return text
114
120
  const playbook = `Obey it for this task:\n\n${books.map((b) => b.text).join("\n\n")}`