@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,357 @@
1
+ import { existsSync, mkdirSync, realpathSync, statSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import type { CodeEvent, Usage } from "../types.ts"
5
+ import { privateEnvNames } from "../childEnv.ts"
6
+ import { addUsage, costOf, meterSince, meterSnapshot, type Cost } from "../usage.ts"
7
+ import { openSession, type OpencodeSession } from "../code/runtime.ts"
8
+ import { runTestCommand, type TestRun } from "../code/testrun.ts"
9
+ import { changedFiles, ownPaths, protectPatterns, restoreRemoved, snapshot } from "../code/worktree.ts"
10
+ import type { AppConfig } from "../config.ts"
11
+ import { MCP_NAME, TOOL_TIMEOUT_MS, localToolServer } from "../mcp/server.ts"
12
+ import { TOOLS, environmentNotes, type ToolContext, type ToolEvent } from "../mcp/tools.ts"
13
+ import { runDirectory } from "../run.ts"
14
+ import {
15
+ healAgentPrompt,
16
+ HEAL_SCHEMA,
17
+ findCommandPrompt,
18
+ firstHealPrompt,
19
+ missingTestsPrompt,
20
+ healNextPrompt,
21
+ remainingPrompt,
22
+ stillFailingPrompt,
23
+ type HealReport,
24
+ type HealVerdict,
25
+ } from "./prompt.ts"
26
+ import { healMarkdown } from "./report.ts"
27
+ import { loadSkills } from "../skills/registry.ts"
28
+ import { domainNotes } from "../domain.ts"
29
+
30
+ export const HEAL_AGENT = "forgeqe-heal"
31
+ const HEAL_TOOLS = new Set(["browser_run", "run_details", "run_test", "page_memory"])
32
+ const FATAL_ERRORS = ["ProviderAuthError", "APIError", "ContextOverflowError", "ContentFilterError"]
33
+
34
+ export type HealStatus = "passed" | "failed" | "stopped" | "clean"
35
+
36
+ export type HealTest = {
37
+ name: string
38
+ file?: string
39
+ status: HealVerdict
40
+ command?: string
41
+ reason?: string
42
+ runDir?: string
43
+ /** ForgeQE ran the test's command itself and it passed. */
44
+ verified?: boolean
45
+ }
46
+
47
+ export type CommandResult = { exitCode: number | null; output: string; ms: number }
48
+
49
+ export type HealResult = {
50
+ status: HealStatus
51
+ repo: string
52
+ command: string
53
+ attempts: number
54
+ maxAttempts: number
55
+ tests: HealTest[]
56
+ changed: string[]
57
+ notes: string
58
+ /** The test command before and after healing, as ForgeQE ran it. */
59
+ before?: CommandResult
60
+ after?: CommandResult
61
+ /** Browser runs the agent made to see what the pages do now. */
62
+ runs: string[]
63
+ usage: Usage
64
+ /** What `usage` cost at the profile's price; unset without one. */
65
+ cost?: Cost
66
+ /** The whole job, browser runs included: tokens and cost from the usage meter. */
67
+ total?: { usage: Usage; cost?: Cost }
68
+ /** Skills the agent loaded. */
69
+ skills?: string[]
70
+ model: string
71
+ dir: string
72
+ startedAt: string
73
+ ms: number
74
+ }
75
+
76
+ export type HealOptions = {
77
+ config: AppConfig
78
+ repo: string
79
+ /** The repo's test command; default code.test_command, else the agent finds it. */
80
+ command?: string
81
+ outDir?: string
82
+ headed?: boolean
83
+ maxSteps?: number
84
+ url?: string
85
+ signal?: AbortSignal
86
+ /** Scribe's progress: tool calls, thoughts, test runs, tokens. */
87
+ onEvent?: (e: CodeEvent) => void
88
+ /** Browser runs the agent starts through ForgeQE's tools. */
89
+ onTool?: (e: ToolEvent) => void
90
+ /** Replaces openSession; tests use it to script the agent. */
91
+ open?: (o: Parameters<typeof openSession<HealReport>>[0]) => Promise<OpencodeSession<HealReport>>
92
+ /** Replaces executeRun for browser_run; tests use it. */
93
+ execute?: ToolContext["execute"]
94
+ }
95
+
96
+ function realpathOr(dir: string): string {
97
+ try {
98
+ return realpathSync(dir)
99
+ } catch {
100
+ return path.resolve(dir)
101
+ }
102
+ }
103
+
104
+ const commandResult = (run: TestRun): CommandResult => ({ exitCode: run.exitCode, output: run.output, ms: run.ms })
105
+
106
+ /**
107
+ * Run the repo's tests; when they fail, Scribe, with ForgeQE's browser tools, heals the tests the app has
108
+ * outgrown and reports real app bugs. ForgeQE re-runs every test the agent calls healed, then the whole command,
109
+ * and only its own runs count.
110
+ */
111
+ export async function runHeal(opts: HealOptions): Promise<HealResult> {
112
+ const given = path.resolve(opts.repo)
113
+ if (!existsSync(given) || !statSync(given).isDirectory()) throw new Error(`Repo not found: ${given}`)
114
+ const repo = realpathSync(given)
115
+ const { config } = opts
116
+ const emit = opts.onEvent || (() => {})
117
+ const t0 = Date.now()
118
+ const meterStart = meterSnapshot()
119
+ let spent: Usage = { input: 0, output: 0 }
120
+ const dir = runDirectory(path.join(config.export.outDir, "heal"), opts.outDir)
121
+ mkdirSync(dir, { recursive: true })
122
+ // Browser runs land inside this heal's folder, and run_details' default (the latest run) finds them there.
123
+ const runsConfig: AppConfig = { ...config, export: { ...config.export, outDir: dir } }
124
+ const maxAttempts = Math.max(1, config.code.maxAttempts)
125
+ const timeoutSeconds = config.code.testTimeoutSeconds
126
+
127
+ const result: HealResult = {
128
+ status: "failed",
129
+ repo,
130
+ command: opts.command || config.code.testCommand || "",
131
+ attempts: 0,
132
+ maxAttempts,
133
+ tests: [],
134
+ changed: [],
135
+ notes: "",
136
+ runs: [],
137
+ usage: { input: 0, output: 0 },
138
+ model: "",
139
+ dir,
140
+ startedAt: new Date(t0).toISOString(),
141
+ ms: 0,
142
+ }
143
+
144
+ const onTool = (e: ToolEvent) => {
145
+ if (e.type === "run-done" && e.runDir && !result.runs.includes(e.runDir)) result.runs.push(e.runDir)
146
+ opts.onTool?.(e)
147
+ }
148
+ const context = (): ToolContext => ({
149
+ config: runsConfig,
150
+ cwd: repo,
151
+ headed: opts.headed,
152
+ maxSteps: opts.maxSteps,
153
+ url: opts.url,
154
+ signal: opts.signal,
155
+ emit: onTool,
156
+ execute: opts.execute,
157
+ })
158
+
159
+ const agent: { tools?: ReturnType<typeof localToolServer>; session?: OpencodeSession<HealReport> } = {}
160
+ // Healing edits tests, never ForgeQE's config, runs, or page memory: denied to the agent, and put back if changed anyway.
161
+ const own = ownPaths(repo, [config.export.outDir, config.memory.dir])
162
+ const outside = [config.export.outDir, config.memory.dir].map((d) => realpathOr(d)).filter((d) => path.relative(repo, d).startsWith(".."))
163
+ const before = snapshot(repo, own)
164
+ const openAgent = async () => {
165
+ const tools = localToolServer({ tools: TOOLS.filter((t) => HEAL_TOOLS.has(t.name)), context, instructions: () => environmentNotes(context()), timeoutMs: TOOL_TIMEOUT_MS })
166
+ agent.tools = tools
167
+ const readDirs = [...new Set([given, config.export.outDir, config.memory.dir].flatMap((d) => [path.resolve(d), realpathOr(d)]))]
168
+ const s = await (opts.open || openSession<HealReport>)({
169
+ repo,
170
+ profile: config.llm,
171
+ domain: domainNotes(config),
172
+ model: config.code.model,
173
+ bin: config.code.opencode,
174
+ readDirs,
175
+ hide: privateEnvNames(config),
176
+ protect: protectPatterns(repo, own, outside),
177
+ signal: opts.signal,
178
+ onEvent: emit,
179
+ agent: { name: HEAL_AGENT, description: "Heals tests the app has outgrown and reports real app bugs", prompt: `${healAgentPrompt()}\n\nThis session:\n${environmentNotes(context())}`, skills: "scribe" },
180
+ skills: loadSkills(config.skills, repo).skills,
181
+ schema: HEAL_SCHEMA as unknown as Record<string, unknown>,
182
+ mcp: { [MCP_NAME]: tools.entry },
183
+ hideTool: (tool) => tool.startsWith(`${MCP_NAME}_`),
184
+ title: "ForgeQE heal",
185
+ })
186
+ agent.session = s
187
+ result.model = s.model
188
+ return s
189
+ }
190
+
191
+ const run = async (command: string) => runTestCommand({ command, cwd: repo, timeoutSeconds, signal: opts.signal, hide: privateEnvNames(config) })
192
+ const stopped = () => Boolean(opts.signal?.aborted)
193
+ const secs = (ms: number) => `${Math.round(ms / 1000)}s`
194
+ const failWhy = (r: TestRun) => (r.exitCode === null ? `timed out after ${timeoutSeconds}s` : `exit ${r.exitCode}`)
195
+
196
+ try {
197
+ if (!result.command) {
198
+ const session = await openAgent()
199
+ emit({ type: "code", kind: "info", text: `Scribe (${session.model}) is finding the test command in ${repo}` })
200
+ const reply = await session.prompt(findCommandPrompt())
201
+ if (stopped()) {
202
+ result.status = "stopped"
203
+ return result
204
+ }
205
+ result.command = reply.report?.suite_command?.trim() || ""
206
+ if (!result.command) {
207
+ result.notes = reply.error || "The agent did not find a test command. Pass one: forgeqe heal \"<command>\", or set code.test_command."
208
+ return result
209
+ }
210
+ }
211
+
212
+ emit({ type: "code", kind: "info", text: `ForgeQE runs the tests: ${result.command}` })
213
+ const baseline = await run(result.command)
214
+ result.before = commandResult(baseline)
215
+ if (stopped()) {
216
+ result.status = "stopped"
217
+ return result
218
+ }
219
+ if (baseline.ok) {
220
+ result.status = "clean"
221
+ result.after = result.before
222
+ emit({ type: "code", kind: "test", ok: true, attempt: 0, text: `All tests pass (${secs(baseline.ms)}); nothing to heal` })
223
+ return result
224
+ }
225
+ emit({ type: "code", kind: "test", ok: false, attempt: 0, text: `Tests failed, ${failWhy(baseline)} (${secs(baseline.ms)})` })
226
+
227
+ let session = agent.session || (await openAgent())
228
+ emit({ type: "code", kind: "info", text: `Scribe (${session.model}) is healing the failing tests in ${repo}` })
229
+ // Each attempt after a failed re-run starts a new session from the state of the job (healNextPrompt), so the
230
+ // model is not sent every earlier attempt's tool calls again.
231
+ const attemptLog: string[] = []
232
+ const freshSession = async (attempt: number, outcome: string, task: string): Promise<string> => {
233
+ attemptLog.push(`${attempt}: ${outcome}`)
234
+ spent = addUsage(spent, session.usage)
235
+ await session.close()
236
+ agent.tools?.server.abortAll()
237
+ agent.tools?.stop()
238
+ session = await openAgent()
239
+ emit({ type: "code", kind: "info", text: `Scribe starts attempt ${attempt + 1} in a fresh session, from a summary of the job so far` })
240
+ return healNextPrompt({ command: result.command, tests: result.tests, changed: changedFiles(repo, result.changed), attempts: attemptLog, task })
241
+ }
242
+ const byName = new Map<string, HealTest>()
243
+ let full: TestRun | undefined
244
+ let verifiedSinceFull = false
245
+ let prompt = firstHealPrompt(result.command, baseline)
246
+
247
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
248
+ result.attempts = attempt
249
+ const reply = await session.prompt(prompt)
250
+ if (stopped()) {
251
+ result.status = "stopped"
252
+ break
253
+ }
254
+ if (reply.error && FATAL_ERRORS.some((name) => reply.error!.startsWith(name))) {
255
+ result.notes = reply.error
256
+ emit({ type: "code", kind: "info", text: `Scribe stopped: ${reply.error}` })
257
+ break
258
+ }
259
+ const report = reply.report
260
+ if (report?.changed) result.changed = [...new Set([...result.changed, ...report.changed])]
261
+ if (report?.notes) result.notes = report.notes
262
+ if (!report?.tests?.length) {
263
+ emit({ type: "code", kind: "test", ok: false, attempt, text: "no failing tests reported yet" })
264
+ prompt = missingTestsPrompt()
265
+ continue
266
+ }
267
+ for (const t of report.tests) {
268
+ const prev = byName.get(t.name)
269
+ const next: HealTest = {
270
+ ...prev,
271
+ name: t.name,
272
+ status: t.status,
273
+ ...(t.file ? { file: t.file } : {}),
274
+ ...(t.command ? { command: t.command } : {}),
275
+ ...(t.reason ? { reason: t.reason } : {}),
276
+ ...(t.run_dir ? { runDir: path.resolve(repo, t.run_dir) } : {}),
277
+ }
278
+ const unchanged = prev?.verified && prev.status === "healed" && t.status === "healed" && (!t.command || t.command === prev.command)
279
+ next.verified = unchanged ? true : undefined
280
+ byName.set(t.name, next)
281
+ }
282
+ result.tests = [...byName.values()]
283
+
284
+ const pending = result.tests.filter((t) => t.status === "healed" && !t.verified)
285
+ if (!pending.length) break
286
+
287
+ const failures: { name: string; command?: string; run?: TestRun }[] = []
288
+ for (const t of pending) {
289
+ if (!t.command) {
290
+ failures.push({ name: t.name })
291
+ emit({ type: "code", kind: "test", ok: false, attempt, text: `${t.name}: no command to run it on its own` })
292
+ continue
293
+ }
294
+ emit({ type: "code", kind: "info", text: `ForgeQE runs: ${t.command}` })
295
+ const r = await run(t.command)
296
+ if (stopped()) break
297
+ if (r.ok) {
298
+ t.verified = true
299
+ verifiedSinceFull = true
300
+ emit({ type: "code", kind: "test", ok: true, attempt, text: `${t.name} passed in ${secs(r.ms)}` })
301
+ } else {
302
+ failures.push({ name: t.name, command: t.command, run: r })
303
+ emit({ type: "code", kind: "test", ok: false, attempt, text: `${t.name} failed, ${failWhy(r)}` })
304
+ }
305
+ }
306
+ if (stopped()) {
307
+ result.status = "stopped"
308
+ break
309
+ }
310
+ if (failures.length) {
311
+ if (attempt < maxAttempts) prompt = await freshSession(attempt, `${failures.map((f) => f.name).join(", ")} still failing`, stillFailingPrompt(failures))
312
+ continue
313
+ }
314
+
315
+ emit({ type: "code", kind: "info", text: `ForgeQE runs the tests: ${result.command}` })
316
+ full = await run(result.command)
317
+ verifiedSinceFull = false
318
+ if (stopped()) {
319
+ result.status = "stopped"
320
+ break
321
+ }
322
+ emit({ type: "code", kind: "test", ok: full.ok, attempt, text: full.ok ? `All tests pass (${secs(full.ms)})` : `Tests still fail, ${failWhy(full)}` })
323
+ if (full.ok) break
324
+ if (attempt < maxAttempts) prompt = await freshSession(attempt, `the tests still fail, ${failWhy(full)}`, remainingPrompt(result.command, full))
325
+ }
326
+
327
+ if (result.status !== "stopped") {
328
+ if (verifiedSinceFull) {
329
+ emit({ type: "code", kind: "info", text: `ForgeQE runs the tests: ${result.command}` })
330
+ full = await run(result.command)
331
+ emit({ type: "code", kind: "test", ok: full.ok, attempt: result.attempts, text: full.ok ? `All tests pass (${secs(full.ms)})` : `Tests still fail, ${failWhy(full)}` })
332
+ }
333
+ if (stopped()) result.status = "stopped"
334
+ else {
335
+ result.after = full ? commandResult(full) : result.before
336
+ result.status = result.after?.exitCode === 0 ? "passed" : "failed"
337
+ }
338
+ }
339
+ return result
340
+ } finally {
341
+ if (agent.session) {
342
+ result.usage = addUsage(spent, agent.session.usage)
343
+ result.cost = costOf(result.usage, config.code.model ? undefined : config.llm.price)
344
+ if (agent.session.skillsUsed?.length) result.skills = [...agent.session.skillsUsed]
345
+ await agent.session.close()
346
+ }
347
+ agent.tools?.server.abortAll()
348
+ agent.tools?.stop()
349
+ const restored = restoreRemoved(before, "own")
350
+ if (restored.length) emit({ type: "code", kind: "info", text: `Put back ${restored.join(", ")}: the agent changed ForgeQE's files` })
351
+ result.ms = Date.now() - t0
352
+ const jobTotal = meterSince(meterStart)
353
+ result.total = { usage: jobTotal.total, cost: jobTotal.cost }
354
+ writeFileSync(path.join(dir, "heal.json"), `${JSON.stringify(result, null, 2)}\n`)
355
+ writeFileSync(path.join(dir, "heal.md"), healMarkdown(result))
356
+ }
357
+ }
@@ -0,0 +1,143 @@
1
+ import type { ChangedFile } from "../code/prompt.ts"
2
+ import { prompt } from "../prompts.ts"
3
+
4
+ /** How to fix tests broken by changes in the app. The heal agent and ForgeQE's own agent both follow it. */
5
+ export const HEAL_BRIEF = `- Work on failing tests only; leave passing tests alone.
6
+ - For each failing test, read it and the page objects, fixtures, and helpers it uses, and work out why it fails:
7
+ - the app changed under the test (an element was renamed or moved, a locator no longer matches, a step was added or removed, text or timing changed): heal the test;
8
+ - the test's own code, config, or environment is broken (an import, a dependency, the base URL, test data): fix that;
9
+ - the app no longer does what the test checks: that is a bug in the app, not in the test.
10
+ - When a failure involves the page, find out what the page does now before changing anything: run the flow the test covers with browser_run, as a plain-language goal that names the start page, each step, and each check the test makes. Do not put locators in the goal.
11
+ - If the browser run passes, the flow still works and the test is out of date. Call run_details for that run and update the test with the steps and the highest-confidence locators it recorded. When the locator lives in a page object, fixture, or helper, fix it there once.
12
+ - If the browser run fails at the step where the test fails, the app is broken there. Do not change the test to make it pass; report it as an app bug with what the browser run found.
13
+ - Keep each test's intent. Never delete or skip a test, loosen or remove an assertion, or raise a timeout just to get a pass.
14
+ - Run each healed test with run_test until it passes, then the whole test command again.`
15
+
16
+ /** System prompt of Scribe behind `forgeqe heal`. */
17
+ export const healAgentPrompt = () => prompt("heal")
18
+
19
+ export const HEAL_SCHEMA = {
20
+ type: "object",
21
+ properties: {
22
+ tests: {
23
+ type: "array",
24
+ description: "Every test that failed, including ones you could not heal",
25
+ items: {
26
+ type: "object",
27
+ properties: {
28
+ name: { type: "string", description: "The test as the runner names it, e.g. tests/login.spec.ts › signs in" },
29
+ file: { type: "string", description: "Test file, relative to the repository root" },
30
+ status: {
31
+ type: "string",
32
+ enum: ["healed", "app_bug", "not_healed"],
33
+ description: "healed: fixed and passing when you run it. app_bug: the app is broken at the step the test checks. not_healed: you could not fix it",
34
+ },
35
+ command: { type: "string", description: "Command, from the repository root, that runs only this test non-interactively; required for healed tests" },
36
+ reason: { type: "string", description: "Why it failed and what you changed, or what is broken in the app" },
37
+ run_dir: { type: "string", description: "Folder of the browser run you used, if any" },
38
+ },
39
+ required: ["name", "status"],
40
+ },
41
+ },
42
+ changed: { type: "array", items: { type: "string" }, description: "Files created or modified" },
43
+ notes: { type: "string", description: "Anything the user should know, in one or two sentences" },
44
+ suite_command: { type: "string", description: "Only when asked for it: the command that runs the repository's tests" },
45
+ },
46
+ required: ["tests"],
47
+ } as const
48
+
49
+ export type HealVerdict = "healed" | "app_bug" | "not_healed"
50
+
51
+ export type HealReport = {
52
+ tests?: { name: string; file?: string; status: HealVerdict; command?: string; reason?: string; run_dir?: string }[]
53
+ changed?: string[]
54
+ notes?: string
55
+ suite_command?: string
56
+ }
57
+
58
+ export type CommandRun = { exitCode: number | null; output: string }
59
+
60
+ function outcome(run: CommandRun): string {
61
+ return run.exitCode === null ? "timed out" : `exited with code ${run.exitCode}`
62
+ }
63
+
64
+ function fenced(output: string): string[] {
65
+ return ["```", output.trimEnd() || "(no output)", "```"]
66
+ }
67
+
68
+ export function findCommandPrompt(): string {
69
+ return "Before fixing anything, find the command that runs this repository's browser or UI tests from the repository root, non-interactively (for example npx playwright test). Do not run it and do not change files yet. Answer with suite_command and an empty tests list."
70
+ }
71
+
72
+ export function firstHealPrompt(command: string, run: CommandRun): string {
73
+ return [
74
+ `ForgeQE ran this repository's tests with \`${command}\` and it ${outcome(run)}. The end of the output:`,
75
+ "",
76
+ ...fenced(run.output),
77
+ "",
78
+ "Heal the failing tests as your instructions describe, then give the structured result for every test that failed.",
79
+ ].join("\n")
80
+ }
81
+
82
+ export function stillFailingPrompt(failures: { name: string; command?: string; run?: CommandRun }[]): string {
83
+ const parts = failures.flatMap((f) =>
84
+ !f.command
85
+ ? [`- ${f.name}: you gave no command that runs only this test.`]
86
+ : [`- ${f.name}: \`${f.command}\` ${outcome(f.run!)}.`, "", ...fenced(f.run!.output), ""],
87
+ )
88
+ return [
89
+ "ForgeQE ran the tests you reported as healed, and these do not pass:",
90
+ "",
91
+ ...parts,
92
+ "Fix them and run them until they pass, or report them as app_bug or not_healed. Then give the structured result again for every test that failed.",
93
+ ].join("\n")
94
+ }
95
+
96
+ export function remainingPrompt(command: string, run: CommandRun): string {
97
+ return [
98
+ `Your healed tests pass on their own, but \`${command}\` still ${outcome(run)}. The end of the output:`,
99
+ "",
100
+ ...fenced(run.output),
101
+ "",
102
+ "Account for every test that still fails: heal it, or report it as app_bug or not_healed with the reason. Then give the structured result again for every test that failed.",
103
+ ].join("\n")
104
+ }
105
+
106
+ export function missingTestsPrompt(): string {
107
+ return "Your result did not list the failing tests. Give the structured result with every test that failed, each as healed, app_bug, or not_healed, with a command that runs only that test."
108
+ }
109
+
110
+ /**
111
+ * The first prompt of a fresh session for the next healing attempt: the state of the job instead of the earlier
112
+ * attempts' conversation, so the model is not sent every earlier tool call again.
113
+ */
114
+ export function healNextPrompt(opts: {
115
+ command: string
116
+ tests: { name: string; status: string; file?: string; command?: string; reason?: string; verified?: boolean }[]
117
+ changed: ChangedFile[]
118
+ attempts: string[]
119
+ task: string
120
+ }): string {
121
+ const files = opts.changed.map((f) =>
122
+ f.content === undefined ? `- ${f.path} (read it if you need it)` : [`### ${f.path}`, "", "```", f.content.trimEnd(), "```"].join("\n"),
123
+ )
124
+ return [
125
+ "You are continuing a healing job in a new session. The earlier attempts' conversation is not here; this message carries what matters from it. Read another file only when the fix needs it.",
126
+ "",
127
+ `The test command: ${opts.command}`,
128
+ "",
129
+ "Your verdicts so far:",
130
+ ...opts.tests.map((t) => `- ${t.name}: ${t.status}${t.verified ? " (ForgeQE confirmed it passes)" : ""}${t.file ? `, ${t.file}` : ""}${t.command ? `, run with: ${t.command}` : ""}${t.reason ? `. ${t.reason.replace(/\s+/g, " ").slice(0, 300)}` : ""}`),
131
+ "",
132
+ "Attempts, in order:",
133
+ ...opts.attempts.map((a) => `- ${a}`),
134
+ "",
135
+ "Files this job changed, as they are now:",
136
+ "",
137
+ ...(files.length ? files : ["(none yet)"]),
138
+ "",
139
+ "## Now",
140
+ "",
141
+ opts.task,
142
+ ].join("\n")
143
+ }
@@ -0,0 +1,95 @@
1
+ import path from "node:path"
2
+
3
+ import { cardText, type Card, type CardStatus } from "../card.ts"
4
+ import { formatDuration, formatTokens } from "../tui/theme.ts"
5
+ import { usageLine } from "../usage.ts"
6
+ import type { HealResult, HealStatus, HealTest } from "./loop.ts"
7
+
8
+ const TITLE: Record<HealStatus, string> = {
9
+ clean: "Nothing to heal",
10
+ passed: "Tests healed",
11
+ failed: "Tests still failing",
12
+ stopped: "Healing stopped",
13
+ }
14
+
15
+ const STATUS: Record<HealStatus, CardStatus> = { clean: "ok", passed: "ok", failed: "fail", stopped: "stopped" }
16
+
17
+ const VERDICT: Record<HealTest["status"], string> = { healed: "healed", app_bug: "app bug", not_healed: "not healed" }
18
+
19
+ function testMark(t: HealTest): string {
20
+ if (t.status === "healed") return t.verified ? "✓" : "?"
21
+ return t.status === "app_bug" ? "!" : "✗"
22
+ }
23
+
24
+ const OUTPUT_CHARS = 4000
25
+
26
+ function tail(s: string): string {
27
+ return s.length <= OUTPUT_CHARS ? s : `…${s.slice(-OUTPUT_CHARS)}`
28
+ }
29
+
30
+ function exit(r: { exitCode: number | null; ms: number }): string {
31
+ return `${r.exitCode === null ? "timed out" : `exit ${r.exitCode}`} after ${formatDuration(r.ms)}`
32
+ }
33
+
34
+ /** heal.md: the command before and after, each failing test's verdict, the browser runs, and the files changed. */
35
+ export function healMarkdown(r: HealResult): string {
36
+ const cell = (s: string) => s.replace(/\|/g, "\\|").replace(/\s+/g, " ").trim()
37
+ const lines = [
38
+ `# ForgeQE heal: ${TITLE[r.status].toLowerCase()}`,
39
+ "",
40
+ `- Repo: ${r.repo}`,
41
+ `- Command: ${r.command ? `\`${r.command}\`` : "(none found)"}`,
42
+ `- Started: ${r.startedAt}, took ${formatDuration(r.ms)}`,
43
+ ...(r.model ? [`- Agent: ${r.model}, ${r.attempts}/${r.maxAttempts} attempts, ${formatTokens(r.usage.input + r.usage.output)} tokens`] : []),
44
+ ...(r.total && (r.total.usage.input || r.total.usage.output) ? [`- Usage: ${usageLine(r.total.usage, r.total.cost)}`] : []),
45
+ ...(r.notes ? ["", r.notes] : []),
46
+ ]
47
+ if (r.tests.length) {
48
+ lines.push("", "## Failing tests", "", "| | Test | Verdict | Why | Browser run |", "|---|---|---|---|---|")
49
+ for (const t of r.tests) {
50
+ const verdict = t.status === "healed" && !t.verified ? "healed (not confirmed by ForgeQE)" : VERDICT[t.status]
51
+ lines.push(`| ${testMark(t)} | ${cell(t.name)} | ${verdict} | ${cell(t.reason || "")} | ${t.runDir ? cell(path.relative(r.dir, t.runDir) || t.runDir) : ""} |`)
52
+ }
53
+ }
54
+ if (r.changed.length) lines.push("", "## Files changed", "", ...r.changed.map((f) => `- ${f}`))
55
+ if (r.runs.length) lines.push("", "## Browser runs", "", ...r.runs.map((d) => `- ${d} (report.html)`))
56
+ for (const [title, run] of [
57
+ ["Before", r.before],
58
+ ["After", r.status === "clean" ? undefined : r.after],
59
+ ] as const) {
60
+ if (run) lines.push("", `## ${title}: ${exit(run)}`, "", "```", tail(run.output).trimEnd() || "(no output)", "```")
61
+ }
62
+ return `${lines.join("\n")}\n`
63
+ }
64
+
65
+ /** The end-of-heal summary, as a card for the shell. */
66
+ export function healCard(r: HealResult, cwd = process.cwd()): Card {
67
+ const stats = [
68
+ r.model ? `${r.attempts}/${r.maxAttempts} ${r.maxAttempts === 1 ? "attempt" : "attempts"}` : "",
69
+ formatDuration(r.ms),
70
+ r.model ? `${formatTokens(r.usage.input + r.usage.output)} tokens` : "",
71
+ r.model,
72
+ ].filter(Boolean)
73
+ const lines: string[] = []
74
+ if (r.status === "clean") lines.push(`${r.command} passes; nothing needed healing.`)
75
+ for (const t of r.tests) {
76
+ const verdict = t.status === "healed" && !t.verified ? "healed, not confirmed" : VERDICT[t.status]
77
+ lines.push(`${testMark(t)} ${t.name} ${verdict}`)
78
+ if (t.reason) lines.push(` ${t.reason.split("\n")[0].slice(0, 200)}`)
79
+ }
80
+ const bugs = r.tests.filter((t) => t.status === "app_bug").length
81
+ if (r.status === "failed" && bugs) lines.push("", `${bugs} failure${bugs === 1 ? " is" : "s are"} in the app, not the tests; the tests were left as they are.`)
82
+ if (r.notes && (r.status === "failed" || r.status === "stopped") && !r.tests.length) lines.push(r.notes)
83
+ const show = (f: string) => path.relative(cwd, f) || f
84
+ lines.push("")
85
+ if (r.command) lines.push(`Command ${r.command}${r.after && r.status !== "clean" ? ` (${r.after.exitCode === 0 ? "passes" : exit(r.after)})` : ""}`)
86
+ if (r.changed.length) lines.push(`Changed ${r.changed.join(", ")}`)
87
+ if (r.total && (r.total.usage.input || r.total.usage.output)) lines.push(`Usage ${usageLine(r.total.usage, r.total.cost)}`)
88
+ lines.push(`Report ${show(path.join(r.dir, "heal.md"))}`)
89
+ return { status: STATUS[r.status], title: TITLE[r.status], stats: stats.join(" · "), body: lines }
90
+ }
91
+
92
+ /** The end-of-heal summary for the terminal. */
93
+ export function formatHeal(r: HealResult, cwd = process.cwd()): string {
94
+ return cardText(healCard(r, cwd))
95
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env bun
2
+ import "./legacyEnv.ts"
3
+ import { main } from "./cli/index.ts"
4
+
5
+ if (import.meta.main) {
6
+ main(process.argv.slice(2))
7
+ .then((code) => process.exit(code))
8
+ .catch((err: unknown) => {
9
+ console.error(err instanceof Error ? err.message : String(err))
10
+ process.exit(1)
11
+ })
12
+ }