@capagents/forgeqe 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/HOW_TO_USE.md +1639 -0
  2. package/README.md +338 -2
  3. package/bin/forgeqe.d.ts +3 -0
  4. package/bin/forgeqe.js +188 -0
  5. package/docs/ARCHITECTURE.md +408 -0
  6. package/docs/security-data-flow.html +1870 -0
  7. package/docs/user-guide.html +2094 -0
  8. package/evals/domain/access-role-change.yaml +37 -0
  9. package/evals/domain/banking-payment-transfer.yaml +41 -0
  10. package/evals/domain/gdpr-account-erasure.yaml +30 -0
  11. package/evals/domain/insurance-claim-fnol.yaml +30 -0
  12. package/evals/domain/insurance-policy-renewal.yaml +37 -0
  13. package/evals/domain/lending-loan-application.yaml +36 -0
  14. package/forgeqe-architecture.html +718 -0
  15. package/forgeqe.example.yaml +203 -0
  16. package/knowledge/README.md +18 -0
  17. package/knowledge/business-rules.md +10 -0
  18. package/knowledge/compliance.md +16 -0
  19. package/knowledge/environments.md +5 -0
  20. package/knowledge/glossary.md +5 -0
  21. package/knowledge/product.md +16 -0
  22. package/knowledge/roles.md +9 -0
  23. package/knowledge/test-data.md +7 -0
  24. package/llms.example.json +51 -0
  25. package/package.json +63 -4
  26. package/prompts/README.md +34 -0
  27. package/prompts/api-scenarios.md +21 -0
  28. package/prompts/api-tests.md +39 -0
  29. package/prompts/brain.md +166 -0
  30. package/prompts/browser-capture.md +7 -0
  31. package/prompts/browser-case-report.md +7 -0
  32. package/prompts/browser-checks.md +14 -0
  33. package/prompts/browser-efficiency.md +7 -0
  34. package/prompts/browser-error-recovery.md +15 -0
  35. package/prompts/browser-extra.md +5 -0
  36. package/prompts/browser-goal-verification.md +13 -0
  37. package/prompts/browser-skill-select.md +6 -0
  38. package/prompts/domain-rule.md +13 -0
  39. package/prompts/eval-judge.md +19 -0
  40. package/prompts/explore-charters.md +27 -0
  41. package/prompts/find-tests.md +28 -0
  42. package/prompts/heal.md +40 -0
  43. package/prompts/pr-review.md +29 -0
  44. package/prompts/skill-rule.md +6 -0
  45. package/prompts/test-agent.md +34 -0
  46. package/prompts/test-reviewer.md +30 -0
  47. package/prompts/test-rules.md +28 -0
  48. package/py/forgeqe_engine/__init__.py +3 -0
  49. package/py/forgeqe_engine/__main__.py +3 -0
  50. package/py/forgeqe_engine/brand.py +453 -0
  51. package/py/forgeqe_engine/brand_assets.py +6 -0
  52. package/py/forgeqe_engine/browser.py +196 -0
  53. package/py/forgeqe_engine/capture.py +538 -0
  54. package/py/forgeqe_engine/checks.py +98 -0
  55. package/py/forgeqe_engine/cli.py +188 -0
  56. package/py/forgeqe_engine/diagnostics.py +609 -0
  57. package/py/forgeqe_engine/llm.py +195 -0
  58. package/py/forgeqe_engine/logins.py +251 -0
  59. package/py/forgeqe_engine/loops.py +224 -0
  60. package/py/forgeqe_engine/memory.py +1004 -0
  61. package/py/forgeqe_engine/network.py +244 -0
  62. package/py/forgeqe_engine/prompts.py +20 -0
  63. package/py/forgeqe_engine/replay.py +240 -0
  64. package/py/forgeqe_engine/report.py +173 -0
  65. package/py/forgeqe_engine/service.py +1335 -0
  66. package/py/forgeqe_engine/settings.py +93 -0
  67. package/py/forgeqe_engine/skills.py +88 -0
  68. package/py/forgeqe_engine/summary.py +239 -0
  69. package/py/forgeqe_engine/urls.py +81 -0
  70. package/py/forgeqe_engine/workflow_yaml.py +689 -0
  71. package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
  72. package/skills/dynamics-365/SKILL.md +21 -0
  73. package/skills/playwright-typescript/SKILL.md +35 -0
  74. package/skills/writing-skills/SKILL.md +30 -0
  75. package/src/api/plan.ts +246 -0
  76. package/src/api/run.ts +204 -0
  77. package/src/api/scenario.ts +502 -0
  78. package/src/api/schema.ts +170 -0
  79. package/src/api/spec.ts +624 -0
  80. package/src/api/store.ts +232 -0
  81. package/src/api/traffic.ts +281 -0
  82. package/src/api/types.ts +163 -0
  83. package/src/api/write.ts +272 -0
  84. package/src/auth/signin.ts +494 -0
  85. package/src/auth/store.ts +170 -0
  86. package/src/brain/index.ts +189 -0
  87. package/src/brain/prompt.ts +19 -0
  88. package/src/capabilities/docs.ts +101 -0
  89. package/src/capabilities/guide.ts +455 -0
  90. package/src/capabilities/registry.ts +343 -0
  91. package/src/capabilities/render.ts +106 -0
  92. package/src/capabilities/status.ts +137 -0
  93. package/src/card.ts +11 -0
  94. package/src/cases/ado.ts +325 -0
  95. package/src/cases/api.ts +117 -0
  96. package/src/cases/bugs.ts +223 -0
  97. package/src/cases/confluence.ts +111 -0
  98. package/src/cases/context.ts +223 -0
  99. package/src/cases/dashboards.ts +137 -0
  100. package/src/cases/failure.ts +97 -0
  101. package/src/cases/files.ts +295 -0
  102. package/src/cases/goal.ts +53 -0
  103. package/src/cases/http.ts +111 -0
  104. package/src/cases/index.ts +131 -0
  105. package/src/cases/jira.ts +122 -0
  106. package/src/cases/qtest.ts +108 -0
  107. package/src/cases/quality.ts +190 -0
  108. package/src/cases/rag.ts +100 -0
  109. package/src/cases/readiness.ts +284 -0
  110. package/src/cases/save.ts +149 -0
  111. package/src/cases/sprints.ts +148 -0
  112. package/src/cases/testrail.ts +176 -0
  113. package/src/cases/types.ts +107 -0
  114. package/src/cases/wiki.ts +105 -0
  115. package/src/cases/xray.ts +206 -0
  116. package/src/cases/zephyr.ts +153 -0
  117. package/src/childEnv.ts +86 -0
  118. package/src/cli/args.ts +114 -0
  119. package/src/cli/help.ts +167 -0
  120. package/src/cli/index.ts +1142 -0
  121. package/src/cli/terminal.ts +187 -0
  122. package/src/code/areas.ts +337 -0
  123. package/src/code/attention.ts +214 -0
  124. package/src/code/find.ts +540 -0
  125. package/src/code/handoff.ts +541 -0
  126. package/src/code/history.ts +170 -0
  127. package/src/code/impact.ts +231 -0
  128. package/src/code/index.ts +34 -0
  129. package/src/code/livebrowser.ts +67 -0
  130. package/src/code/loop.ts +603 -0
  131. package/src/code/notes.ts +75 -0
  132. package/src/code/profile.ts +226 -0
  133. package/src/code/prompt.ts +303 -0
  134. package/src/code/prreview.ts +649 -0
  135. package/src/code/prtests.ts +103 -0
  136. package/src/code/replay.ts +772 -0
  137. package/src/code/review.ts +202 -0
  138. package/src/code/runtime.ts +781 -0
  139. package/src/code/slices.ts +159 -0
  140. package/src/code/testrun.ts +187 -0
  141. package/src/code/traceability.ts +100 -0
  142. package/src/code/worktree.ts +153 -0
  143. package/src/commands.ts +101 -0
  144. package/src/config.ts +970 -0
  145. package/src/detail.ts +65 -0
  146. package/src/domain.ts +19 -0
  147. package/src/engine/bridge.ts +359 -0
  148. package/src/engine/mock.ts +102 -0
  149. package/src/engine/setup.ts +298 -0
  150. package/src/entra.ts +154 -0
  151. package/src/env.ts +24 -0
  152. package/src/evals/domain.ts +338 -0
  153. package/src/explore/charters.ts +151 -0
  154. package/src/explore/run.ts +280 -0
  155. package/src/explore/story.ts +106 -0
  156. package/src/guard.ts +51 -0
  157. package/src/heal/loop.ts +357 -0
  158. package/src/heal/prompt.ts +143 -0
  159. package/src/heal/report.ts +95 -0
  160. package/src/index.ts +12 -0
  161. package/src/init.ts +102 -0
  162. package/src/knowledge.ts +114 -0
  163. package/src/legacyEnv.ts +9 -0
  164. package/src/llm.ts +61 -0
  165. package/src/locale.ts +25 -0
  166. package/src/logins.ts +148 -0
  167. package/src/managed.ts +101 -0
  168. package/src/mcp/apitools.ts +335 -0
  169. package/src/mcp/captools.ts +25 -0
  170. package/src/mcp/casetools.ts +223 -0
  171. package/src/mcp/commands.ts +421 -0
  172. package/src/mcp/exploretools.ts +129 -0
  173. package/src/mcp/external.ts +172 -0
  174. package/src/mcp/git.ts +78 -0
  175. package/src/mcp/notifytools.ts +49 -0
  176. package/src/mcp/prtools.ts +261 -0
  177. package/src/mcp/qualitytools.ts +154 -0
  178. package/src/mcp/repotests.ts +286 -0
  179. package/src/mcp/server.ts +234 -0
  180. package/src/mcp/tools.ts +810 -0
  181. package/src/memory.ts +250 -0
  182. package/src/money.ts +39 -0
  183. package/src/monitor/alerts.ts +94 -0
  184. package/src/monitor/background.ts +68 -0
  185. package/src/monitor/run.ts +263 -0
  186. package/src/monitor/state.ts +105 -0
  187. package/src/notify/events.ts +144 -0
  188. package/src/notify/notice.ts +177 -0
  189. package/src/prices.ts +175 -0
  190. package/src/print.ts +54 -0
  191. package/src/privacy.ts +35 -0
  192. package/src/prompts.ts +165 -0
  193. package/src/quality/budgets.ts +249 -0
  194. package/src/quality/history.ts +149 -0
  195. package/src/quality/visual.ts +201 -0
  196. package/src/report/data.ts +475 -0
  197. package/src/report/html.ts +106 -0
  198. package/src/report/template.html +84 -0
  199. package/src/run.ts +243 -0
  200. package/src/sessionLog.ts +216 -0
  201. package/src/sessionLogins.ts +94 -0
  202. package/src/skills/registry.ts +217 -0
  203. package/src/skills/scaffold.ts +65 -0
  204. package/src/skills/select.ts +37 -0
  205. package/src/skills/view.ts +44 -0
  206. package/src/suite/report.ts +135 -0
  207. package/src/suite/run.ts +303 -0
  208. package/src/summary.ts +188 -0
  209. package/src/tools.ts +130 -0
  210. package/src/tui/App.tsx +983 -0
  211. package/src/tui/clipboard.ts +36 -0
  212. package/src/tui/components.tsx +263 -0
  213. package/src/tui/dialogs.ts +65 -0
  214. package/src/tui/main.tsx +20 -0
  215. package/src/tui/text.ts +28 -0
  216. package/src/tui/theme.ts +104 -0
  217. package/src/tui/transcript.tsx +344 -0
  218. package/src/types.ts +146 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
@@ -0,0 +1,421 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import { docsContents, readDocs } from "../capabilities/docs.ts"
5
+ import { noAgentModel } from "../llm.ts"
6
+ import { latestRunDir } from "../code/index.ts"
7
+ import { runCodeAgent } from "../code/loop.ts"
8
+ import { parseDuration, reloadLogins, replayMode, type AppConfig } from "../config.ts"
9
+ import { engineDoctor, ensureEngine } from "../engine/setup.ts"
10
+ import { runHeal } from "../heal/loop.ts"
11
+ import { healCard } from "../heal/report.ts"
12
+ import { initWorkspace } from "../init.ts"
13
+ import { clearLogins, describeLogins, signIn } from "../logins.ts"
14
+ import { forgetSessionLogins, rememberLogin, sessionLogins } from "../sessionLogins.ts"
15
+ import { backgroundMonitors, startBackgroundMonitor, stopBackgroundMonitors } from "../monitor/background.ts"
16
+ import { roundCard, runMonitor, sendTestAlert, type RoundResult } from "../monitor/run.ts"
17
+ import { readState } from "../monitor/state.ts"
18
+ import { findSkill, loadSkills, skillBody, type Skill, type SkillAgent } from "../skills/registry.ts"
19
+ import { ignoredNote, newSkill } from "../skills/scaffold.ts"
20
+ import { buildCodeSummary, codeCard } from "../summary.ts"
21
+ import { externalMcp } from "./external.ts"
22
+ import type { McpTool, ToolContext } from "./tools.ts"
23
+
24
+ const clip = (s: string | undefined, n: number) => {
25
+ const t = (s || "").trim()
26
+ return t.length <= n ? t : `…${t.slice(-(n - 1))}`
27
+ }
28
+
29
+ /** Marks a browser_run that was a task (a lookup in a tool), not a flow to automate; write_test refuses such runs. */
30
+ export const TASK_FILE = "task.json"
31
+
32
+ const inCwd = (ctx: ToolContext, p: unknown) => (p ? path.resolve(ctx.cwd, String(p)) : ctx.cwd)
33
+
34
+ function readJson(file: string): unknown {
35
+ try {
36
+ return JSON.parse(readFileSync(file, "utf8"))
37
+ } catch {
38
+ return null
39
+ }
40
+ }
41
+
42
+ function needsModel(cfg: AppConfig, what: string) {
43
+ const why = noAgentModel(cfg.llm, cfg.code.model, what)
44
+ if (why) throw new Error(why)
45
+ }
46
+
47
+ function hhmm(d: Date | string): string {
48
+ const t = new Date(d)
49
+ return `${String(t.getHours()).padStart(2, "0")}:${String(t.getMinutes()).padStart(2, "0")}`
50
+ }
51
+
52
+ function every(ms: number): string {
53
+ return ms % 3_600_000 === 0 ? `${ms / 3_600_000}h` : ms % 60_000 === 0 ? `${ms / 60_000}m` : `${Math.round(ms / 1000)}s`
54
+ }
55
+
56
+ function channels(cfg: AppConfig): string[] {
57
+ const a = cfg.monitor.alerts
58
+ return [a.teams ? `Teams (${a.teams.webhookEnv})` : "", a.email ? `email to ${a.email.to.join(", ")}` : ""].filter(Boolean)
59
+ }
60
+
61
+ /** monitor: from forgeqe.yaml with the tool's every/replay, and check references resolved from the working directory. */
62
+ function monitorSetup(args: Record<string, any>, ctx: ToolContext): { cfg: AppConfig; checks: string[] } {
63
+ const m = { ...ctx.config.monitor }
64
+ if (args.every) {
65
+ m.everyMs = parseDuration(args.every, "every")
66
+ if (m.everyMs < 10_000) throw new Error("every must be at least 10s.")
67
+ }
68
+ if (args.replay) m.replay = replayMode(args.replay)
69
+ const refs: string[] = Array.isArray(args.checks) && args.checks.length ? args.checks.map(String) : m.checks
70
+ if (!refs.length) throw new Error("No checks: pass checks, or set monitor.checks in forgeqe.yaml.")
71
+ const checks = refs.map((r) => (existsSync(path.resolve(ctx.cwd, r)) ? path.resolve(ctx.cwd, r) : r))
72
+ return { cfg: { ...ctx.config, monitor: m }, checks }
73
+ }
74
+
75
+ function roundSummary(r: RoundResult) {
76
+ return {
77
+ passed: r.suite ? r.suite.results.filter((x) => x.status === "passed").length : 0,
78
+ total: r.suite?.results.length ?? 0,
79
+ error: r.error,
80
+ stopped: r.stopped || undefined,
81
+ failing: r.failing.map((f) => ({ id: f.id, title: f.title, since: f.since, rounds: f.failures, alerted: Boolean(f.alertedAt), summary: clip(f.summary, 300) })),
82
+ alerts: r.alerts.map((a) => ({ kind: a.kind, id: a.id, title: a.title })),
83
+ sent: r.sent,
84
+ report: r.suite?.files.html,
85
+ }
86
+ }
87
+
88
+ /** The CLI's commands as tools, so ForgeQE's agent (and any MCP client) can do everything the CLI does. */
89
+ export const COMMAND_TOOLS: McpTool[] = [
90
+ {
91
+ name: "write_test",
92
+ description:
93
+ "Turn a passed browser run into a test in a repository with ForgeQE's coding agent (forgeqe code): it studies the repository's framework, page objects, fixtures, and helpers, writes the test to fit, and ForgeQE runs the test itself until it passes in a browser and a read-only reviewer approves it against the goal, or the attempts run out. Returns the test file, the command that runs only that test, whether ForgeQE's own run passed, the browser it ran in, and the review. Takes minutes.",
94
+ inputSchema: {
95
+ type: "object",
96
+ properties: {
97
+ run_dir: { type: "string", description: "Run folder from browser_run or run_test_cases (default: the latest run)" },
98
+ repo: { type: "string", description: "Repository to write the test in, relative to the working directory (default: the working directory)" },
99
+ },
100
+ },
101
+ async run(args, ctx) {
102
+ const cfg = ctx.config
103
+ needsModel(cfg, "the coding agent")
104
+ const runDir = args.run_dir ? path.resolve(ctx.cwd, String(args.run_dir)) : latestRunDir(cfg.export.outDir)
105
+ if (!runDir || !existsSync(runDir)) throw new Error("No run found. Run a goal first.")
106
+ const task = readJson(path.join(runDir, TASK_FILE)) as { reason?: string } | null
107
+ if (task) throw new Error(`No test is written for this run: ${task.reason || "it was a task, not a flow to test"}.`)
108
+ const t0 = Date.now()
109
+ const repo = inCwd(ctx, args.repo)
110
+ const goal = (readJson(path.join(runDir, "run.json")) as { goal?: string } | null)?.goal || ""
111
+ ctx.emit?.({ type: "code-start", runDir, repo, goal })
112
+ let r
113
+ try {
114
+ r = await runCodeAgent({
115
+ config: cfg,
116
+ repo,
117
+ runDir,
118
+ signal: ctx.signal,
119
+ onEvent: (event) => ctx.emit?.({ type: "agent", event }),
120
+ open: ctx.openSession as never,
121
+ runTest: ctx.runTest,
122
+ })
123
+ } catch (err) {
124
+ ctx.emit?.({ type: "code-done", error: (err as Error).message, ms: Date.now() - t0 })
125
+ throw err
126
+ }
127
+ ctx.emit?.({ type: "code-done", result: r, ms: Date.now() - t0 })
128
+ ctx.emit?.({ type: "card", card: codeCard(buildCodeSummary(r, Date.now() - t0)) })
129
+ return {
130
+ status: r.status,
131
+ test_file: r.testFile,
132
+ command: r.command,
133
+ attempts: `${r.attempts}/${r.maxAttempts}`,
134
+ reused: r.reused,
135
+ changed: r.changed,
136
+ notes: r.notes || undefined,
137
+ browsers: r.browsers,
138
+ review: r.review,
139
+ output: r.status === "passed" ? undefined : clip(r.output, 4000),
140
+ }
141
+ },
142
+ },
143
+ {
144
+ name: "heal_tests",
145
+ description:
146
+ "Fix a repository's broken tests (forgeqe heal): ForgeQE runs the test command; for each failing test a healing agent re-runs its flow in the real browser, updates the test's steps and locators when the flow still works (the app changed), or reports an app bug and leaves the test alone when the browser fails at the same step. It never deletes, skips, or loosens a test. ForgeQE re-runs every healed test and then the whole command itself. Returns each test's verdict, the files changed, and heal.md. Takes minutes.",
147
+ inputSchema: {
148
+ type: "object",
149
+ properties: {
150
+ command: { type: "string", description: "Command that runs the tests, e.g. npx playwright test tests/checkout (default: code.test_command, or the healing agent finds it)" },
151
+ repo: { type: "string", description: "Repository, relative to the working directory (default: the working directory)" },
152
+ },
153
+ },
154
+ async run(args, ctx) {
155
+ needsModel(ctx.config, "the healing agent")
156
+ const r = await runHeal({
157
+ config: ctx.config,
158
+ repo: inCwd(ctx, args.repo),
159
+ command: args.command ? String(args.command) : undefined,
160
+ headed: ctx.headed,
161
+ maxSteps: ctx.maxSteps,
162
+ url: ctx.url,
163
+ signal: ctx.signal,
164
+ onEvent: (event) => ctx.emit?.({ type: "agent", event }),
165
+ onTool: (e) => ctx.emit?.(e),
166
+ open: ctx.openSession as never,
167
+ execute: ctx.execute,
168
+ })
169
+ ctx.emit?.({ type: "card", card: healCard(r, ctx.cwd) })
170
+ return {
171
+ status: r.status,
172
+ command: r.command,
173
+ tests: r.tests.map((t) => ({ name: t.name, file: t.file, verdict: t.status, confirmed_by_forgeqe: Boolean(t.verified), reason: t.reason })),
174
+ changed: r.changed,
175
+ notes: r.notes || undefined,
176
+ report: path.join(r.dir, "heal.md"),
177
+ output: r.status === "failed" ? clip(r.after?.output, 4000) : undefined,
178
+ }
179
+ },
180
+ },
181
+ {
182
+ name: "monitor",
183
+ description:
184
+ "Scheduled checks with Teams and email alerts (forgeqe monitor; settings under monitor: in forgeqe.yaml). round: run the checks once now, update which are failing, and alert on what changed. start: run rounds every interval in the background while this ForgeQE session stays open; each round is shown as it finishes. stop: stop background monitors. status: which checks are failing and since when, and what runs in the background. test_alerts: send a sample alert to every channel.",
185
+ inputSchema: {
186
+ type: "object",
187
+ properties: {
188
+ action: { type: "string", enum: ["round", "start", "stop", "status", "test_alerts"] },
189
+ checks: {
190
+ type: "array",
191
+ items: { type: "string" },
192
+ description: "Test case sources as forgeqe suite takes them: files or folders, jira:SHOP-1,SHOP-2, jira:jql=<query>, ado:plan=12/suite=34, xray:plan=SHOP-100, testrail:run=45, zephyr:cycle=SHOP-R5, qtest:cycle=88 (default: monitor.checks)",
193
+ },
194
+ every: { type: "string", description: "Interval between rounds, e.g. 5m, 15m, 1h (default: monitor.every)" },
195
+ replay: { type: "string", enum: ["verify", "trust", "off"], description: "Page memory replay (default: monitor.replay)" },
196
+ },
197
+ required: ["action"],
198
+ },
199
+ async run(args, ctx) {
200
+ const action = String(args.action)
201
+ const outDir = ctx.config.monitor.outDir
202
+ if (action === "stop") {
203
+ const stopped = await stopBackgroundMonitors()
204
+ for (const m of stopped) ctx.emit?.({ type: "info", text: `Monitor stopped after ${m.rounds} round${m.rounds === 1 ? "" : "s"}: ${m.checks.join(" ")}`, ok: true })
205
+ return { stopped: stopped.map((m) => ({ checks: m.checks, rounds: m.rounds, out_dir: m.outDir })) }
206
+ }
207
+ if (action === "status") {
208
+ const state = readState(outDir)
209
+ return {
210
+ settings: { checks: ctx.config.monitor.checks, every: every(ctx.config.monitor.everyMs), fail_after: ctx.config.monitor.failAfter, alerts: channels(ctx.config), out_dir: outDir },
211
+ checks: Object.entries(state.checks).map(([id, s]) => ({ id, title: s.title, status: s.status, since: s.since, failing_rounds: s.failures || undefined, alerted_at: s.alertedAt })),
212
+ last_round: state.updatedAt,
213
+ background: backgroundMonitors().map((m) => ({
214
+ checks: m.checks,
215
+ every: every(m.everyMs),
216
+ started: m.startedAt.toISOString(),
217
+ rounds: m.rounds,
218
+ next_round: m.nextAt?.toISOString(),
219
+ last: m.last ? roundSummary(m.last) : undefined,
220
+ })),
221
+ }
222
+ }
223
+ if (action === "test_alerts") {
224
+ if (!channels(ctx.config).length) throw new Error("No alert channels are set up. Add monitor.alerts.teams or monitor.alerts.email to forgeqe.yaml.")
225
+ const sent = await sendTestAlert(ctx.config)
226
+ for (const s of sent) ctx.emit?.({ type: "info", text: s.ok ? `Test alert sent to ${s.channel === "teams" ? "Teams" : "email"}` : `${s.channel === "teams" ? "Teams" : "Email"}: ${s.error}`, ok: s.ok })
227
+ return { sent }
228
+ }
229
+ const { cfg, checks } = monitorSetup(args, ctx)
230
+ if (action === "start") {
231
+ if (ctx.session !== "shell" && ctx.session !== "server") {
232
+ throw new Error("A background monitor would stop when this command exits. Use forgeqe monitor (in a service or tmux), or forgeqe monitor --once from cron or a scheduled pipeline.")
233
+ }
234
+ startBackgroundMonitor({ config: cfg, checks, headed: ctx.headed, url: ctx.url, execute: ctx.execute, cwd: ctx.cwd, onCard: (card) => ctx.emit?.({ type: "card", card }) })
235
+ ctx.emit?.({ type: "info", text: `Monitoring ${checks.map((c) => path.relative(ctx.cwd, c) || c).join(" ")} every ${every(cfg.monitor.everyMs)} until ForgeQE closes`, ok: true })
236
+ return { started: true, checks, every: every(cfg.monitor.everyMs), replay: cfg.monitor.replay, alerts: channels(cfg), out_dir: outDir, note: "The first round runs now. The monitor stops when this ForgeQE session closes." }
237
+ }
238
+ if (action !== "round") throw new Error(`Unknown action "${action}".`)
239
+ const bg = backgroundMonitors().find((m) => m.outDir === outDir)
240
+ if (bg) throw new Error(`A background monitor is running in ${outDir}${bg.nextAt ? `; its next round is at ${hhmm(bg.nextAt)}` : ""}. Use status, or stop it first.`)
241
+ let result: RoundResult | undefined
242
+ await runMonitor({
243
+ config: cfg,
244
+ checks,
245
+ once: true,
246
+ headed: ctx.headed,
247
+ url: ctx.url,
248
+ signal: ctx.signal,
249
+ execute: ctx.execute,
250
+ onEvent: (event) => ctx.emit?.({ type: "suite-event", event }),
251
+ onRound: (r) => {
252
+ result = r
253
+ },
254
+ })
255
+ ctx.emit?.({ type: "card", card: roundCard(result!, undefined, ctx.cwd) })
256
+ return roundSummary(result!)
257
+ },
258
+ },
259
+ {
260
+ name: "logins",
261
+ description:
262
+ "Saved logins. remember: credentials the user gave in a message or a test case become a login for this ForgeQE session; the browser agent signs in with the exact values (typed from placeholders, never shown to the browser model or written to reports), the signed-in session is saved, and later runs start signed in. Call it before the first browser run or run_test_cases that needs them, and never put the credentials in a goal. list: logins from forgeqe.yaml and this session, with their sites and saved sessions. sign_in: open a visible browser where the user signs in by hand (single sign-on, captcha, security key) and ForgeQE saves the session; the user confirms in ForgeQE when done. clear: forget a saved session (and a remembered login's credentials).",
263
+ inputSchema: {
264
+ type: "object",
265
+ properties: {
266
+ action: { type: "string", enum: ["list", "remember", "sign_in", "clear"] },
267
+ name: { type: "string", description: "Login name: from forgeqe.yaml, or a short name for remember such as dynamics" },
268
+ url: { type: "string", description: "remember: the site the user signs in to. sign_in: page to open instead of the login's url" },
269
+ domains: {
270
+ type: "array",
271
+ items: { type: "string" },
272
+ description: "remember: every other host the sign-in happens on, e.g. login.microsoftonline.com for Microsoft sign-in, accounts.google.com, *.okta.com",
273
+ },
274
+ username: { type: "string", description: "remember: exactly as given, without surrounding quotes or the sentence's punctuation" },
275
+ password: { type: "string", description: "remember: exactly as given, without surrounding quotes or the sentence's punctuation" },
276
+ totp_secret: { type: "string", description: "remember: an authenticator secret (base32), when given" },
277
+ all: { type: "boolean", description: "clear: every saved session" },
278
+ },
279
+ required: ["action"],
280
+ },
281
+ async run(args, ctx) {
282
+ const action = String(args.action)
283
+ if (action === "remember") {
284
+ if (!args.name) throw new Error("remember needs a name.")
285
+ const login = rememberLogin(ctx.config, {
286
+ name: String(args.name),
287
+ url: args.url ? String(args.url) : undefined,
288
+ domains: Array.isArray(args.domains) ? args.domains.map(String) : undefined,
289
+ username: args.username ? String(args.username) : undefined,
290
+ password: args.password ? String(args.password) : undefined,
291
+ totp: args.totp_secret ? String(args.totp_secret) : undefined,
292
+ })
293
+ const text = `Remembered login ${login.name} for ${login.domains.join(", ")} for this session`
294
+ ctx.emit?.({ type: "info", text, ok: true })
295
+ return {
296
+ remembered: login.name,
297
+ domains: login.domains,
298
+ has: Object.keys(login.values),
299
+ session_file: login.sessionFile,
300
+ next_step: `Browser goals for this site say "sign in with the saved login ${login.name}" and never contain the credentials; the browser agent types them itself. Later runs reuse the signed-in session.`,
301
+ env_names_for_tests: [login.usernameEnv, login.passwordEnv, login.totpEnv].filter(Boolean),
302
+ }
303
+ }
304
+ reloadLogins(ctx.config)
305
+ if (action === "list") {
306
+ const remembered = sessionLogins()
307
+ const extra = remembered.map((l) => `${l.name} ${l.domains.join(", ")}\n credentials remembered for this session (${Object.keys(l.values).join(", ")})\n session ${l.sessionFile}`)
308
+ const base = ctx.config.logins.length || !remembered.length ? describeLogins(ctx.config) : ""
309
+ return [base, ...extra].filter(Boolean).join("\n\n")
310
+ }
311
+ if (action === "clear") {
312
+ const forgotten = forgetSessionLogins(args.all === true ? undefined : args.name ? String(args.name) : "")
313
+ const configured = args.all === true || ctx.config.logins.some((l) => l.name === args.name)
314
+ const cleared = configured || !forgotten.length ? clearLogins(ctx.config, args.name ? String(args.name) : undefined, args.all === true) : ""
315
+ const text = [cleared, forgotten.length ? `Forgot the remembered credentials for ${forgotten.join(", ")}.` : ""].filter(Boolean).join(" ")
316
+ ctx.emit?.({ type: "info", text, ok: true })
317
+ return text
318
+ }
319
+ if (action !== "sign_in") throw new Error(`Unknown action "${action}".`)
320
+ const name = args.name ? String(args.name) : undefined
321
+ if (!ctx.waitForUser) throw new Error(`Signing in by hand needs someone at ForgeQE's terminal: run forgeqe login ${name || "<name>"}.`)
322
+ const r = await signIn(ctx.config, name, {
323
+ url: args.url ? String(args.url) : undefined,
324
+ signal: ctx.signal,
325
+ log: (text) => ctx.emit?.({ type: "info", text }),
326
+ wait: ctx.waitForUser,
327
+ })
328
+ ctx.emit?.({ type: "info", text: r.text.split("\n")[0], ok: r.status === "saved" })
329
+ return r
330
+ },
331
+ },
332
+ {
333
+ name: "forgeqe_setup",
334
+ description:
335
+ "ForgeQE's own setup. profiles: the model profiles in llms.json and which is active. mcp_servers: the MCP servers ForgeQE's settings give its agent, and why any are skipped. docs: ForgeQE's documentation (README and HOW_TO_USE): without sections, its contents (every section's id and heading); with sections, those sections' exact text, and a top-level section also lists its subsections' ids. Read the contents, pick the sections a question needs, then read them; use it to answer how ForgeQE works or is configured. install_engine: install or repair the browser engine (Python, its libraries, Chrome); force reinstalls. init: create forgeqe.yaml and llms.json in a folder, with editable copies of the agents' prompts (.forgeqe/prompts), skills (.forgeqe/skills), and product knowledge templates (.forgeqe/knowledge). skills: list the skills each agent can use (op list), read one (op show), or create one in .forgeqe/skills (op new, only when the user asks for a skill).",
336
+ inputSchema: {
337
+ type: "object",
338
+ properties: {
339
+ action: { type: "string", enum: ["profiles", "mcp_servers", "docs", "install_engine", "init", "skills"] },
340
+ op: { type: "string", enum: ["list", "show", "new"], description: "skills: what to do (default list)" },
341
+ name: { type: "string", description: "skills show/new: the skill's name, lowercase words joined by hyphens, e.g. dynamics-date-picker" },
342
+ description: { type: "string", description: "skills new: when the skill applies (repository, framework, site, or task), one or two sentences; agents choose skills by it" },
343
+ agents: { type: "array", items: { type: "string", enum: ["forgeqe", "scribe", "browser"] }, description: "skills new: who uses it: scribe for test-writing conventions, browser for how to drive a site or widget, forgeqe for request workflows (default forgeqe and scribe)" },
344
+ body: { type: "string", description: "skills new: the instructions, as Markdown: short imperative rules, concrete names, paths, and commands" },
345
+ user: { type: "boolean", description: "skills new: save in ~/.forgeqe/skills for every project instead of this project's .forgeqe/skills" },
346
+ sections: { type: "array", items: { type: "string" }, description: "docs: section ids from the contents, e.g. HOW_TO_USE.md#api-and-contract-tests" },
347
+ force: { type: "boolean", description: "install_engine: reinstall. init: overwrite existing files" },
348
+ dir: { type: "string", description: "init: folder, relative to the working directory (default: the working directory)" },
349
+ profile: { type: "string", description: "init: active profile to write (default: the llms.json default)" },
350
+ },
351
+ required: ["action"],
352
+ },
353
+ async run(args, ctx) {
354
+ const cfg = ctx.config
355
+ switch (String(args.action)) {
356
+ case "profiles":
357
+ return {
358
+ active: cfg.activeProfile,
359
+ config: cfg.configPath,
360
+ profiles: Object.entries(cfg.profiles).map(([name, p]) => ({ name, provider: p.provider, model: p.deployment || p.model, active: name === cfg.activeProfile })),
361
+ note: "The user switches profiles with Tab or /profile in the shell, or --profile.",
362
+ }
363
+ case "mcp_servers": {
364
+ const { servers, skipped } = externalMcp(cfg)
365
+ return {
366
+ servers: servers.map((s) => {
367
+ const c = s.config as { type: string; command?: string[]; url?: string }
368
+ return { name: s.name, label: s.label, runs: c.type === "local" ? c.command?.join(" ") : c.url }
369
+ }),
370
+ skipped: skipped.map((s) => ({ label: s.label, why: s.why })),
371
+ }
372
+ }
373
+ case "docs":
374
+ return Array.isArray(args.sections) && args.sections.length ? readDocs(args.sections.map(String)) : { contents: docsContents() }
375
+ case "install_engine": {
376
+ const python = await ensureEngine((text) => ctx.emit?.({ type: "info", text }), { force: args.force === true })
377
+ const doc = engineDoctor(python)
378
+ if (!doc.browser_use) throw new Error(`The engine check failed for ${python}: ${doc.error}`)
379
+ ctx.emit?.({ type: "info", text: `Engine ready (${doc.browser_use})`, ok: true })
380
+ return { python: `${python} (${doc.python})`, engine: doc.browser_use, browser: doc.executable_path || doc.channel }
381
+ }
382
+ case "init": {
383
+ const dir = inCwd(ctx, args.dir)
384
+ const r = initWorkspace(dir, { force: args.force === true, profile: args.profile ? String(args.profile) : undefined })
385
+ for (const f of r.created) ctx.emit?.({ type: "info", text: `created ${f}`, ok: true })
386
+ return { dir, created: r.created, notes: r.notes }
387
+ }
388
+ case "skills": {
389
+ const reg = loadSkills(cfg.skills, ctx.cwd)
390
+ const op = args.op ? String(args.op) : "list"
391
+ if (op === "list") {
392
+ if (!cfg.skills.enabled) return { enabled: false, note: "Skills are turned off in forgeqe.yaml (skills: false)." }
393
+ const row = (s: Skill) => ({ name: s.name, description: s.description, agents: s.agents, source: s.source, ...(s.overrides ? { replaces: s.overrides } : {}) })
394
+ return {
395
+ skills: reg.skills.map(row),
396
+ disabled: reg.disabled.map((s) => s.name),
397
+ problems: reg.problems,
398
+ folders: { project: cfg.skills.projectDir, user: cfg.skills.userDir },
399
+ }
400
+ }
401
+ const name = String(args.name || "").trim()
402
+ if (!name) throw new Error(`skills ${op} needs a name.`)
403
+ if (op === "show") {
404
+ const skill = findSkill(reg, name)
405
+ if (!skill) throw new Error(`No skill named "${name}". Skills: ${reg.skills.map((s) => s.name).join(", ") || "none"}.`)
406
+ return { name, description: skill.description, agents: skill.agents, source: skill.source, file: skill.file, disabled: reg.disabled.includes(skill), instructions: skillBody(skill) }
407
+ }
408
+ if (op !== "new") throw new Error(`Unknown skills op "${op}".`)
409
+ const root = args.user === true ? cfg.skills.userDir : cfg.skills.projectDir
410
+ const agents = Array.isArray(args.agents) ? args.agents.map(String) : []
411
+ const file = newSkill(root, { name, description: String(args.description || ""), agents: agents as SkillAgent[], body: args.body ? String(args.body) : undefined })
412
+ ctx.emit?.({ type: "info", text: `created ${file}`, ok: true })
413
+ const ignored = args.user === true ? null : ignoredNote(file)
414
+ return { file, note: `Agents pick it up in their next session (a new ForgeQE session, or the next run or test).${ignored ? ` ${ignored}` : ""}` }
415
+ }
416
+ default:
417
+ throw new Error(`Unknown action "${String(args.action)}".`)
418
+ }
419
+ },
420
+ },
421
+ ]
@@ -0,0 +1,129 @@
1
+ import path from "node:path"
2
+
3
+ import type { Card } from "../card.ts"
4
+ import { planCharters, type Charter } from "../explore/charters.ts"
5
+ import { exploreCharters, explorationCard, fileConfirmed, proposedCases, saveExploration } from "../explore/run.ts"
6
+ import { readStory } from "../explore/story.ts"
7
+ import { allLogins } from "../sessionLogins.ts"
8
+ import { addUsage } from "../usage.ts"
9
+ import type { McpTool } from "./tools.ts"
10
+
11
+ const MAX_CHARTERS = 12
12
+ const charters_ = (n: number) => `${n} exploratory charter${n === 1 ? "" : "s"}`
13
+
14
+ export function planCard(storyLabel: string, charters: Charter[], notes?: string): Card {
15
+ const body: string[] = []
16
+ for (const c of charters) {
17
+ body.push(`▸ ${c.id} ${c.title} · ${c.category.replace("_", " ")}${c.role ? ` · as ${c.role}` : ""}${c.changesData ? " · changes data" : ""}`)
18
+ body.push(` ${c.mission}`)
19
+ c.steps.forEach((s, i) => body.push(` ${i + 1}. ${s.action}${s.data ? ` (${s.data})` : ""} → ${s.expected}`))
20
+ }
21
+ if (notes) body.push("", notes)
22
+ return { status: "info", title: `${charters_(charters.length)} for ${storyLabel}`, body }
23
+ }
24
+
25
+ /** Exploratory testing of a story. */
26
+ export const EXPLORE_TOOLS: McpTool[] = [
27
+ {
28
+ name: "explore_story",
29
+ description:
30
+ "Exploratory testing of a user story in real browsers, beyond its scripted test cases. Reads the story (Azure DevOps or Jira: description, acceptance criteria, feature and epic, the test cases that already script it; or the story's text), plans charters (other paths and data, edge cases, invalid input, boundaries, role differences, refresh/back/double submit, what must not happen), shows them and asks before running, then runs each charter in the browser (as its role's saved login) and judges each expected result on the page. A possible defect runs a second time and counts only when it happens again. Returns confirmed defects, observations (not reproduced, browser errors, what the agent noticed), and proposed test cases; files bugs for confirmed defects only, after the user agrees. Takes several minutes: one browser run per charter.",
31
+ inputSchema: {
32
+ type: "object",
33
+ properties: {
34
+ story: { type: "string", description: "The story: ado:123, an Azure DevOps or Jira link, a Jira key (SHOP-12), or the story's text with its acceptance criteria" },
35
+ url: { type: "string", description: "The page where the feature starts" },
36
+ charters: { type: "integer", description: `How many charters (default 6, at most ${MAX_CHARTERS}): each is one browser run, two for a possible defect` },
37
+ roles: { type: "array", items: { type: "string" }, description: "Saved logins (logins: in forgeqe.yaml) to compare as roles. Default: every saved login when there are two or more" },
38
+ focus: { type: "string", description: "What to concentrate on, when the user says (e.g. 'validation of the address form', 'what a viewer can do')" },
39
+ file_bugs: { type: "boolean", description: "Where nobody can be asked: file bugs for confirmed defects without asking" },
40
+ },
41
+ required: ["story"],
42
+ },
43
+ async run(args, ctx) {
44
+ const emit = ctx.emit || (() => {})
45
+ const ref = String(args.story || "").trim()
46
+ if (!ref) throw new Error("story is required.")
47
+ const story = await readStory(ctx.config, ref)
48
+ const label = story.id === "story" ? `"${story.title}"` : `${story.id} ${story.title}`
49
+ emit({ type: "info", text: `Story ${label}: ${story.acceptance ? "acceptance criteria read" : "no acceptance criteria"}, ${story.scripted.length} scripted test case${story.scripted.length === 1 ? "" : "s"}${story.parents.length ? `, under ${story.parents.map((p) => p.id).join(" > ")}` : ""}` })
50
+ for (const e of story.errors) emit({ type: "info", text: `Not read: ${e}`, ok: false })
51
+ const logins = allLogins(ctx.config).map((l) => l.name)
52
+ const roles = (Array.isArray(args.roles) ? args.roles.map(String) : logins.length >= 2 ? logins : []).filter((r) => logins.includes(r))
53
+ const count = Math.max(1, Math.min(MAX_CHARTERS, Number(args.charters) || 6))
54
+ const url = args.url ? String(args.url) : ctx.url
55
+ if (args.focus) story.description = `${story.description}\n\nFocus of this session: ${String(args.focus)}`.trim()
56
+ const plan = await planCharters({ config: ctx.config, story, count, roles, url, cwd: ctx.cwd, signal: ctx.signal, onEvent: (event) => emit({ type: "agent", event }), open: ctx.openSession })
57
+ emit({ type: "card", card: planCard(label, plan.charters, plan.notes) })
58
+ let charters = plan.charters
59
+ if (ctx.ask) {
60
+ const writes = charters.filter((c) => c.changesData).length
61
+ const answer = await ctx.ask({
62
+ title: `Run ${charters_(charters.length)}?`,
63
+ detail: `${charters.length} browser run${charters.length === 1 ? "" : "s"}${url ? ` from ${url}` : ""}, and one more for each possible defect.${writes ? ` ${writes} create or change data: use a test environment.` : ""}`,
64
+ options: [
65
+ { id: "all", label: "Run them" },
66
+ ...(writes && writes < charters.length ? [{ id: "safe", label: "Only those that change no data" }] : []),
67
+ { id: "cancel", label: "Cancel" },
68
+ ],
69
+ })
70
+ if (answer === null || answer === "cancel") return { cancelled: true, charters: charters.map((c) => c.title), reason: "The user did not run the charters." }
71
+ if (answer === "safe") charters = charters.filter((c) => !c.changesData)
72
+ }
73
+ const dir = path.join(ctx.config.export.outDir, "explore", `${new Date().toISOString().replace(/[-:]/g, "").replace(/\..*/, "").replace("T", "-")}-${(story.id === "story" ? story.title : story.id).toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40)}`)
74
+ const results = await exploreCharters({
75
+ config: ctx.config,
76
+ story,
77
+ charters,
78
+ dir,
79
+ url,
80
+ headed: ctx.headed,
81
+ maxSteps: ctx.maxSteps,
82
+ signal: ctx.signal,
83
+ execute: ctx.execute,
84
+ events: {
85
+ onRunStart: (c, _goal, repro) => emit({ type: "run-start", goal: `${c.id} ${repro ? "again, to confirm: " : ""}${c.title}${c.role ? ` (as ${c.role})` : ""}`, url }),
86
+ onRunEvent: (_c, event) => emit({ type: "run-event", event }),
87
+ onRunDone: (_c, r) => emit({ type: "run-done", result: r.result, error: r.error, files: [], runDir: r.runDir, ms: r.ms }),
88
+ onInfo: (text) => emit({ type: "info", text }),
89
+ },
90
+ })
91
+ const confirmed = results.filter((r) => r.status === "defect")
92
+ let bugs: Awaited<ReturnType<typeof fileConfirmed>> = []
93
+ if (confirmed.length && ctx.config.sources.bugs) {
94
+ const yes = ctx.ask
95
+ ? (await ctx.ask({
96
+ title: `File ${confirmed.length} bug${confirmed.length === 1 ? "" : "s"} for the confirmed defects?`,
97
+ detail: `In ${ctx.config.sources.bugs.system === "jira" ? "Jira" : "Azure DevOps"}. Each happened on two runs. Observations that did not reproduce are not filed.`,
98
+ lines: confirmed.map((r) => `• ${r.charter.title}: ${r.failure?.reason || ""}`.slice(0, 200)),
99
+ options: [
100
+ { id: "yes", label: "File them" },
101
+ { id: "no", label: "Not now" },
102
+ ],
103
+ })) === "yes"
104
+ : args.file_bugs === true
105
+ if (yes) bugs = await fileConfirmed(ctx.config, story, results)
106
+ }
107
+ const proposed = proposedCases(results)
108
+ const browser = results.reduce((u, r) => addUsage(u, r.usage), { input: 0, output: 0 })
109
+ const x = { dir, story, results, proposed, bugs, usage: { plan: plan.usage, browser } }
110
+ const report = saveExploration(x)
111
+ emit({ type: "card", card: explorationCard({ ...x, report }, ctx.cwd) })
112
+ return {
113
+ story: { id: story.id, system: story.system, title: story.title, url: story.url },
114
+ confirmed_defects: confirmed.map((r) => ({ charter: r.charter.title, what: r.failure?.reason, run_dir: r.runDir, repro_run_dir: r.reproRunDir })),
115
+ not_reproduced: results.filter((r) => r.status === "not_reproduced").map((r) => ({ charter: r.charter.title, what: r.observations[0] })),
116
+ could_not_explore: results.filter((r) => r.status === "could_not_explore").map((r) => ({ charter: r.charter.title, why: r.failure ? `${r.failure.type}: ${r.failure.reason}` : r.summary })),
117
+ observations: results.flatMap((r) => r.observations.map((o) => `${r.charter.id}: ${o}`)).slice(0, 30),
118
+ bugs: bugs.length ? bugs : undefined,
119
+ bugs_not_set_up: confirmed.length && !ctx.config.sources.bugs ? "No bug tracker is set up (bugs: in forgeqe.yaml), so no bugs were filed." : undefined,
120
+ proposed_test_cases: proposed.map(({ from, kind, ...c }) => ({ ...c, from_charter: from, kind })),
121
+ report,
122
+ next_step:
123
+ story.system === "text"
124
+ ? "Report the confirmed defects and observations. Offer to save the proposed test cases once the user names the story they belong to."
125
+ : `Report the confirmed defects (with their bugs) and observations. Offer to save the proposed test cases to the story with save_test_cases (system ${story.system === "ado" ? "azure_devops" : "jira"}, story ${story.system === "ado" ? story.id.replace(/^ADO-/, "") : story.id}), after the user picks which.`,
126
+ }
127
+ },
128
+ },
129
+ ]