@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,603 @@
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import type { CodeEvent, CodeResult, CodeReview, Usage } from "../types.ts"
5
+ import { privateEnvNames } from "../childEnv.ts"
6
+ import { addCost, addUsage, costOf } from "../usage.ts"
7
+ import type { AppConfig } from "../config.ts"
8
+ import { buildHandoff, type KnownDefect } from "./handoff.ts"
9
+ import type { TestCase } from "../cases/types.ts"
10
+ import { buildProfile, profileSection } from "./profile.ts"
11
+ import { LIVE_BROWSER_SERVER, liveBrowser } from "./livebrowser.ts"
12
+ import { addWrittenTest, notesSection, readNotes, saveNotes } from "./notes.ts"
13
+ import { canReplay, replayRun, savedReplay, type Replay } from "./replay.ts"
14
+ import { openSession, READ_AGENT, TEST_AGENT, type OpencodeSession, type PromptOutcome } from "./runtime.ts"
15
+ import { failureContext } from "./slices.ts"
16
+ import { moveToAttention } from "./attention.ts"
17
+ import { recordMapping } from "./traceability.ts"
18
+ import {
19
+ blockedChallengePrompt,
20
+ carryOnPrompt,
21
+ firstPrompt,
22
+ browserMismatch,
23
+ fixPrompt,
24
+ LIVE_ON_NOTE,
25
+ missingCommandPrompt,
26
+ nextAttemptPrompt,
27
+ noBrowserPrompt,
28
+ preparePrompt,
29
+ repeatedFailurePrompt,
30
+ restoredNote,
31
+ RESUMED_NOTE,
32
+ reviewFixPrompt,
33
+ stallPrompt,
34
+ standardsFixPrompt,
35
+ type AgentReport,
36
+ type ExistingTest,
37
+ } from "./prompt.ts"
38
+ import { openReviewer, type Reviewer } from "./review.ts"
39
+ import { runTestCommand } from "./testrun.ts"
40
+ import { changedFiles, jobChanges, ownPaths, preexisting, protectPatterns, restoreRemoved, restoreSnapshot, snapshot, snapshotFiles } from "./worktree.ts"
41
+ import { loadSkills } from "../skills/registry.ts"
42
+ import { domainNotes } from "../domain.ts"
43
+
44
+ function realpathOr(dir: string): string {
45
+ try {
46
+ return realpathSync(dir)
47
+ } catch {
48
+ return path.resolve(dir)
49
+ }
50
+ }
51
+
52
+ /** Reviewer rejections of a passing test before the job stops. */
53
+ const MAX_REJECTIONS = 2
54
+
55
+ const FATAL_ERRORS = ["ProviderAuthError", "APIError", "ContextOverflowError", "ContentFilterError"]
56
+ /** Turn errors after which the same session is worth reopening on a rerun (the model was unreachable or refused the key). */
57
+ const RESUMABLE_ERRORS = ["ProviderAuthError", "APIError"]
58
+
59
+ export const SESSION_FILE = "scribe-session.json"
60
+ type SavedSession = { sessionId: string; repo: string; stage: "reading" | "writing"; liveOn?: boolean; at?: string }
61
+
62
+ /** Scribe's session on this run and repo, left by a job that stopped before it finished. */
63
+ export function savedSession(runDir: string, repo: string): SavedSession | null {
64
+ try {
65
+ const s = JSON.parse(readFileSync(path.join(runDir, SESSION_FILE), "utf8")) as SavedSession
66
+ return s.sessionId && s.repo === repo ? s : null
67
+ } catch {
68
+ return null
69
+ }
70
+ }
71
+
72
+ /** A test run's output without its numbers (timings, line numbers, counts), to tell the same failure again. */
73
+ /** One line of the attempt log a fresh session starts from: what ran, how it ended, and what Scribe said it did. */
74
+ function attemptLine(attempt: number, outcome: string, detail: string, notes?: string): string {
75
+ const clip = (t: string, n: number) => (t.length > n ? `${t.slice(0, n - 1)}…` : t)
76
+ return [`${attempt}: ${outcome}`, detail ? `: ${clip(detail.replace(/\s+/g, " ").trim(), 240)}` : "", notes ? ` (Scribe: ${clip(notes.replace(/\s+/g, " ").trim(), 240)})` : ""].join("")
77
+ }
78
+
79
+ /** The first line of a test run's output that names the error. */
80
+ function firstError(output: string): string {
81
+ const lines = output.split("\n").map((l) => l.trim()).filter(Boolean)
82
+ return lines.find((l) => /error|exception|failed|timeout|expected|strict mode/i.test(l) && !/^at /.test(l)) || lines[0] || ""
83
+ }
84
+
85
+ export function failureKey(output: string): string {
86
+ return output.replace(/\d+/g, "#").replace(/\s+/g, " ").trim()
87
+ }
88
+
89
+ /**
90
+ * Hand a passed browser run to Scribe and loop until the test it writes passes when ForgeQE runs it.
91
+ * The agent's own claim of success does not count. An attempt passes only when ForgeQE's run of the reported
92
+ * command exits 0 with a browser launched under it, and a read-only reviewer approves the change against the goal.
93
+ */
94
+ export async function runCodeAgent(opts: {
95
+ config: AppConfig
96
+ repo: string
97
+ runDir: string
98
+ signal?: AbortSignal
99
+ onEvent?: (e: CodeEvent) => void
100
+ /** Replaces openSession; tests use it to script the agent. */
101
+ open?: (o: Parameters<typeof openSession>[0]) => Promise<OpencodeSession>
102
+ /** Replaces runTestCommand. */
103
+ runTest?: typeof runTestCommand
104
+ /** Replaces the Scribe reviewer. */
105
+ reviewer?: Reviewer
106
+ /** Replaces replayRun. */
107
+ replay?: typeof replayRun
108
+ /** The test case the run came from: the test is named and tagged for it, and mapped to it once it passes. */
109
+ testCase?: TestCase
110
+ /** The run found a functional defect: the test asserts the correct behaviour, marked as an expected failure. */
111
+ knownDefect?: KnownDefect
112
+ /** The test the case already has, which failed when ForgeQE ran it: the job updates it. */
113
+ existingTest?: ExistingTest
114
+ }): Promise<CodeResult> {
115
+ const given = path.resolve(opts.repo)
116
+ if (!existsSync(given) || !statSync(given).isDirectory()) throw new Error(`Repo not found: ${given}`)
117
+ const repo = realpathSync(given)
118
+ const { config } = opts
119
+ const emit = opts.onEvent || (() => {})
120
+ let handoff = buildHandoff(opts.runDir, config.memory.dir, { knownDefect: opts.knownDefect })
121
+ if (!handoff.success && !opts.knownDefect) throw new Error("The coding agent only takes runs that passed. This run failed.")
122
+ const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`
123
+ const shown = path.relative(process.cwd(), handoff.file)
124
+ const handed = [plural(handoff.steps, "step"), handoff.checks ? plural(handoff.checks, "check") : "", handoff.pages ? `${plural(handoff.pages, "page")} from page memory` : ""]
125
+ emit({ type: "code", kind: "info", text: `Handoff written: ${shown && !shown.startsWith("..") ? shown : handoff.file} (${handed.filter(Boolean).join(", ")})` })
126
+ const maxAttempts = Math.max(1, config.code.maxAttempts)
127
+
128
+ const result: CodeResult = {
129
+ status: "failed",
130
+ repo,
131
+ attempts: 0,
132
+ maxAttempts,
133
+ reused: [],
134
+ changed: [],
135
+ notes: "",
136
+ output: "",
137
+ usage: { input: 0, output: 0 },
138
+ model: "",
139
+ handoff: handoff.file,
140
+ }
141
+
142
+ const readDirs = [...new Set([given, opts.runDir, config.memory.dir].flatMap((d) => [path.resolve(d), realpathOr(d)]))]
143
+ const own = ownPaths(repo, [opts.runDir, config.memory.dir])
144
+ // How the run's browser was set up, to tell Scribe when its test's browser differs (headless, another browser).
145
+ const runBrowser = (() => {
146
+ try {
147
+ return (JSON.parse(readFileSync(path.join(opts.runDir, "run.json"), "utf8")) as { browser?: { headless?: boolean; channel?: string } }).browser
148
+ } catch {
149
+ return undefined
150
+ }
151
+ })()
152
+ const before = snapshot(repo, own)
153
+ const outside = [opts.runDir, config.memory.dir].map((d) => realpathOr(d)).filter((d) => path.relative(repo, d).startsWith(".."))
154
+ let session: OpencodeSession | null = null
155
+ const reviewer = opts.reviewer || openReviewer({ config, repo, readDirs, signal: opts.signal, onEvent: emit, open: opts.open })
156
+ const runTest = opts.runTest || runTestCommand
157
+ let challenged = false
158
+ let stalls = 0
159
+ let lastFailure = ""
160
+ let rejections = 0
161
+ /** The passing, goal-approved version, kept while the agent fixes coding standards in one more round. */
162
+ let tidy: { files: Map<string, Buffer | null>; report: AgentReport; review: CodeReview; output: string; browsers: string[] } | null = null
163
+ let tidying = false
164
+ let sameFailures = 0
165
+ const live = config.code.liveBrowser ? liveBrowser(opts.runDir) : null
166
+ let liveOn = false
167
+ const replaying = Boolean(config.code.replay) && canReplay(opts.runDir)
168
+ const tools = () => (live ? { tools: { [`${LIVE_BROWSER_SERVER}_*`]: liveOn } } : {})
169
+ const earlier = savedSession(opts.runDir, repo)
170
+ const notesDir = config.memory.enabled ? config.memory.dir : ""
171
+ const known = notesDir ? readNotes(notesDir, repo) : null
172
+ let stage: SavedSession["stage"] = "reading"
173
+ let keepSession = true
174
+ let runtimeFailed = false
175
+ const saveSession = () => {
176
+ if (!session) return
177
+ const saved: SavedSession = { sessionId: session.sessionId, repo, stage, liveOn, at: new Date().toISOString() }
178
+ try {
179
+ mkdirSync(opts.runDir, { recursive: true })
180
+ writeFileSync(path.join(opts.runDir, SESSION_FILE), `${JSON.stringify(saved, null, 2)}\n`)
181
+ } catch {
182
+ // Only a rerun after a crash reads it.
183
+ }
184
+ }
185
+ let replay: Replay | null = null
186
+ /** The replay's page snapshots, from the pass that reached the run's last page first, and its failing steps'. */
187
+ const replaySnapshots = (): string[] => {
188
+ const passes = [...(replay?.passes || [])].sort((a, b) => Number(b.reached) - Number(a.reached))
189
+ return passes.flatMap((p) => [...(p.pages || []).map((pg) => pg.snapshot), ...p.steps.flatMap((st) => (st.snapshot ? [st.snapshot] : []))])
190
+ }
191
+ const switchLiveOn = (): string => {
192
+ if (!live || liveOn) return ""
193
+ liveOn = true
194
+ saveSession()
195
+ emit({ type: "code", kind: "info", text: "Scribe's live browser is on to look at the failing step" })
196
+ return `\n\n${LIVE_ON_NOTE}`
197
+ }
198
+ const keepHandoff = () => {
199
+ if (existsSync(handoff.file)) return
200
+ mkdirSync(path.dirname(handoff.file), { recursive: true })
201
+ writeFileSync(handoff.file, handoff.markdown)
202
+ }
203
+ // code.max_tokens holds during an attempt too: the turn is stopped as soon as the job's total passes it, instead of
204
+ // after the attempt (one long attempt can use millions of tokens on its own).
205
+ let overAt: number | null = null
206
+ const watchBudget = (e: CodeEvent) => {
207
+ emit(e)
208
+ if (e.kind !== "usage" || !config.code.maxTokens || overAt !== null) return
209
+ const used = addUsage(addUsage(spent, e.usage), reviewer.usage)
210
+ if (used.input + used.output < config.code.maxTokens) return
211
+ overAt = used.input + used.output
212
+ void session?.abort()
213
+ }
214
+ const sessionOptions = (resume?: string) => ({
215
+ repo,
216
+ profile: config.llm,
217
+ domain: domainNotes(config),
218
+ model: config.code.model,
219
+ bin: config.code.opencode,
220
+ readDirs,
221
+ hide: privateEnvNames(config),
222
+ signal: opts.signal,
223
+ onEvent: watchBudget,
224
+ protect: protectPatterns(repo, own, outside),
225
+ stallAfter: config.code.stallAfter ?? 5,
226
+ agent: { ...TEST_AGENT, steps: config.code.maxSteps ?? 50 },
227
+ agents: [READ_AGENT],
228
+ skills: loadSkills(config.skills, repo).skills,
229
+ ...(live ? { mcp: { [LIVE_BROWSER_SERVER]: live.config } } : {}),
230
+ ...(resume ? { resume } : {}),
231
+ })
232
+ // Each attempt after the first runs in a new session from a brief of the job so far (nextAttemptPrompt), so the
233
+ // model is not sent every earlier attempt's tool calls again. Usage of closed sessions is kept here.
234
+ let spent: Usage = { input: 0, output: 0 }
235
+ const attemptLog: string[] = []
236
+ let nextTask: string | null = null
237
+ let notesText: string | undefined = known ? notesSection(known) : undefined
238
+ // The repository's test setup, read by ForgeQE without a model; Scribe starts from it instead of a reading turn.
239
+ const repoProfile = buildProfile(repo)
240
+ const profileText = repoProfile ? profileSection(repoProfile) : undefined
241
+ try {
242
+ session = await (opts.open || openSession)(sessionOptions(earlier?.sessionId))
243
+ result.model = session.model
244
+ result.sessionId = session.sessionId
245
+ const resumed = Boolean(earlier && session.resumed)
246
+ if (resumed) result.resumed = true
247
+ if (earlier) {
248
+ emit({
249
+ type: "code",
250
+ kind: "info",
251
+ text: resumed ? "Scribe reopens its session from the earlier job on this run, which stopped before it finished" : "Scribe's session from the earlier job on this run is gone; it starts a new one",
252
+ })
253
+ }
254
+ saveSession()
255
+ emit({ type: "code", kind: "info", text: `Scribe, the coding agent (${session.model}), reads the handoff and writes a test in ${repo}` })
256
+
257
+ let prepared = true
258
+ const reads = !resumed && !known && !repoProfile
259
+ if (repoProfile && !resumed) {
260
+ emit({
261
+ type: "code",
262
+ kind: "info",
263
+ text: `ForgeQE read the repository's test setup itself (${repoProfile.pageObjects.length} page objects, ${repoProfile.baseClasses.length} base classes, ${repoProfile.settings.length} settings files${repoProfile.testIdAttribute ? `, test ids in ${repoProfile.testIdAttribute.value}` : ""}); Scribe starts from it`,
264
+ })
265
+ }
266
+ if (known && !resumed) {
267
+ const n = known.tests.length
268
+ emit({ type: "code", kind: "info", text: `Scribe starts from its notes on this repository (${known.at.slice(0, 10)}${n ? `, ${plural(n, "test")} written before` : ""}) instead of reading it again` })
269
+ }
270
+ const saved = replaying && resumed ? savedReplay(opts.runDir) : null
271
+ if (replaying) {
272
+ const replayText = reads ? "ForgeQE replays the run's steps in Playwright while Scribe reads the repository" : "ForgeQE replays the run's steps in Playwright"
273
+ emit({ type: "code", kind: "info", text: saved ? "ForgeQE uses the Playwright replay the earlier job finished" : replayText })
274
+ }
275
+ const [r, prep]: [Replay | null, PromptOutcome | null] = await Promise.all([
276
+ !replaying ? null : saved || (opts.replay || replayRun)({ runDir: opts.runDir, signal: opts.signal, hide: privateEnvNames(config), log: (text) => emit({ type: "code", kind: "info", text }) }),
277
+ reads ? session.prompt(preparePrompt(handoff.goal, replaying), { structured: false, agent: READ_AGENT.name }) : null,
278
+ ])
279
+ if (prep && !prep.error && notesDir) saveNotes(notesDir, repo, prep.text)
280
+ if (prep && !prep.error && prep.text.trim()) notesText = `Your notes on this repository, from reading it at the start of this job:\n${prep.text.trim()}`
281
+ if (r) {
282
+ replay = r
283
+ result.replay = {
284
+ dir: r.dir,
285
+ ...(r.error ? { error: r.error } : {}),
286
+ passes: r.passes.map((p) => ({ setup: p.setup, failed: p.steps.filter((s) => !s.ok && !s.skipped).map((s) => s.n), reached: p.reached })),
287
+ }
288
+ handoff = buildHandoff(opts.runDir, config.memory.dir, { replay, knownDefect: opts.knownDefect })
289
+ // The replay's failure (its error, screenshot, and page snapshot) is in the handoff; the live browser waits for a repeated failure.
290
+ liveOn = Boolean(live) && Boolean(resumed && earlier?.liveOn)
291
+ } else if (resumed && earlier?.liveOn) {
292
+ liveOn = Boolean(live)
293
+ }
294
+ if (opts.signal?.aborted) {
295
+ result.status = "stopped"
296
+ prepared = false
297
+ } else if (prep?.error && FATAL_ERRORS.some((name) => prep.error!.startsWith(name))) {
298
+ result.notes = prep.error
299
+ runtimeFailed = RESUMABLE_ERRORS.some((name) => prep.error!.startsWith(name))
300
+ emit({ type: "code", kind: "info", text: `Scribe stopped: ${prep.error}` })
301
+ prepared = false
302
+ }
303
+ if (live && prepared) {
304
+ emit({
305
+ type: "code",
306
+ kind: "info",
307
+ text: liveOn ? "Scribe's live browser is on, as it was when the earlier job stopped" : "Scribe gets a live browser (Playwright, set up like ForgeQE's) if a test failure repeats",
308
+ })
309
+ }
310
+ let prompt =
311
+ resumed && earlier?.stage === "writing"
312
+ ? carryOnPrompt(liveOn)
313
+ : `${resumed ? `${RESUMED_NOTE}\n\n` : ""}${firstPrompt(handoff, { ...(live ? { liveBrowserDir: live.dir, liveOn } : {}), ...(known ? { notes: notesSection(known) } : {}), ...(profileText ? { profile: profileText } : {}), ...(opts.existingTest ? { existing: opts.existingTest } : {}) })}`
314
+ if (prepared) {
315
+ stage = "writing"
316
+ saveSession()
317
+ }
318
+ let report: AgentReport = {}
319
+ let note = ""
320
+ const overBudget = (): number | null => {
321
+ if (!config.code.maxTokens || !session) return null
322
+ const used = addUsage(addUsage(spent, session.usage), reviewer.usage)
323
+ return used.input + used.output >= config.code.maxTokens ? used.input + used.output : null
324
+ }
325
+ /** The tidy-up round broke the test: put back the version that passed, and pass with the standards issues noted. */
326
+ const finishWithKept = (attempt: number, why: string) => {
327
+ restoreSnapshot(repo, before, tidy!.files)
328
+ report = { ...tidy!.report }
329
+ result.testFile = report.test_file
330
+ result.command = report.command
331
+ result.changed = report.changed || []
332
+ result.output = tidy!.output
333
+ result.browsers = tidy!.browsers
334
+ result.status = "passed"
335
+ const left = tidy!.review.issues.filter((i) => i.kind === "standards")
336
+ result.review = { ...tidy!.review, approved: true, standardsLeft: true }
337
+ result.notes = `The test passes and tests the goal. ${why[0].toUpperCase()}${why.slice(1)}, so ForgeQE put back the version that passed. Coding standards still to fix: ${left.map((i) => `${i.file ? `${i.file}: ` : ""}${i.problem}`).join(" ")}`
338
+ if (notesDir && report.test_file && report.command) addWrittenTest(notesDir, repo, { goal: handoff.goal, test_file: report.test_file, command: report.command, changed: result.changed })
339
+ emit({ type: "code", kind: "test", ok: true, attempt, text: `${why}; kept the version that passed (${report.test_file}), with the coding-standard issues in the notes` })
340
+ }
341
+ // The tidy-up round for coding standards does not count against max_attempts.
342
+ let allowed = maxAttempts
343
+ for (let attempt = 1; prepared && attempt <= allowed; attempt++) {
344
+ const over = overBudget()
345
+ if (over !== null) {
346
+ result.notes = `Stopped: the job used ${over.toLocaleString("en")} tokens, over code.max_tokens (${config.code.maxTokens!.toLocaleString("en")}). ${result.notes || ""}`.trim()
347
+ emit({ type: "code", kind: "test", ok: false, attempt, text: `stopped: over the token budget (code.max_tokens ${config.code.maxTokens!.toLocaleString("en")})` })
348
+ break
349
+ }
350
+ result.attempts = attempt
351
+ if (nextTask !== null) {
352
+ spent = addUsage(spent, session!.usage)
353
+ await session!.close()
354
+ session = await (opts.open || openSession)(sessionOptions())
355
+ result.sessionId = session.sessionId
356
+ saveSession()
357
+ emit({ type: "code", kind: "info", text: `Scribe starts attempt ${attempt} in a fresh session, from a summary of the job so far` })
358
+ prompt = nextAttemptPrompt({
359
+ handoff,
360
+ notes: [profileText, notesText].filter(Boolean).join("\n\n") || undefined,
361
+ testFile: report.test_file,
362
+ command: report.command,
363
+ changed: changedFiles(repo, jobChanges(before) || report.changed || []),
364
+ attempts: attemptLog,
365
+ task: nextTask,
366
+ ...(live ? { liveBrowserDir: live.dir, liveOn } : {}),
367
+ })
368
+ nextTask = null
369
+ }
370
+ const reply = await session!.prompt(note + prompt, tools())
371
+ if (overAt !== null && !opts.signal?.aborted) {
372
+ restoreRemoved(before)
373
+ keepHandoff()
374
+ result.notes = `Stopped: the job used ${(overAt as number).toLocaleString("en")} tokens, over code.max_tokens (${config.code.maxTokens!.toLocaleString("en")}), during attempt ${attempt}. ${report.notes || ""}`.trim()
375
+ emit({ type: "code", kind: "test", ok: false, attempt, text: `stopped: over the token budget (code.max_tokens ${config.code.maxTokens!.toLocaleString("en")}) during attempt ${attempt}` })
376
+ break
377
+ }
378
+ const restored = restoreRemoved(before)
379
+ keepHandoff()
380
+ if (restored.length) emit({ type: "code", kind: "info", text: `Put back ${restored.join(", ")}: the coding agent removed or reverted files that existed before the job` })
381
+ note = restored.length ? `${restoredNote(restored)}\n\n` : ""
382
+ if (opts.signal?.aborted) {
383
+ result.status = "stopped"
384
+ break
385
+ }
386
+ if (reply.stalled) {
387
+ const { command, count } = reply.stalled
388
+ if (stalls++ === 0) {
389
+ emit({ type: "code", kind: "test", ok: false, attempt, text: `${command} failed ${count} times; the agent is asked to change approach` })
390
+ const on = switchLiveOn()
391
+ prompt = stallPrompt(reply.stalled, Boolean(live)) + on
392
+ session.resetStall?.()
393
+ continue
394
+ }
395
+ result.notes = `Stopped: the coding agent ran ${command} ${count} times and it failed each time, again after being asked to change approach. Last output:\n${reply.stalled.output}`
396
+ emit({ type: "code", kind: "test", ok: false, attempt, text: `stopped: ${command} kept failing (${count} times) after the agent was asked to change approach` })
397
+ break
398
+ }
399
+ if (reply.report) report = { ...report, ...reply.report }
400
+ result.notes = report.notes || reply.text.slice(0, 500)
401
+ if (reply.error && FATAL_ERRORS.some((name) => reply.error!.startsWith(name))) {
402
+ result.notes = reply.error
403
+ runtimeFailed = RESUMABLE_ERRORS.some((name) => reply.error!.startsWith(name))
404
+ emit({ type: "code", kind: "info", text: `Scribe stopped: ${reply.error}` })
405
+ break
406
+ }
407
+ if (report.status === "blocked" && !challenged) {
408
+ challenged = true
409
+ report = { ...report, status: undefined }
410
+ prompt = blockedChallengePrompt(handoff) + switchLiveOn()
411
+ emit({ type: "code", kind: "test", ok: false, attempt, text: `reported blocked; ForgeQE's browser reached the app, so the agent compares the two browsers first` })
412
+ continue
413
+ }
414
+ if (report.status === "blocked") {
415
+ result.status = "blocked"
416
+ result.notes = report.blocked_reason || report.notes || result.notes
417
+ result.testFile = report.test_file
418
+ result.changed = report.changed || []
419
+ emit({ type: "code", kind: "test", ok: false, attempt, text: `blocked: ${result.notes || "the application blocked the test"}` })
420
+ break
421
+ }
422
+ if (!report.command || !report.test_file) {
423
+ prompt = missingCommandPrompt()
424
+ emit({ type: "code", kind: "test", ok: false, attempt, text: "no test command reported yet" })
425
+ continue
426
+ }
427
+ result.testFile = report.test_file
428
+ result.command = report.command
429
+ result.reused = report.reused || []
430
+ result.changed = report.changed || []
431
+
432
+ emit({ type: "code", kind: "info", text: `ForgeQE runs the test: ${report.command}` })
433
+ const run = await runTest({
434
+ command: report.command,
435
+ cwd: repo,
436
+ timeoutSeconds: config.code.testTimeoutSeconds,
437
+ signal: opts.signal,
438
+ save: path.join(opts.runDir, "test-runs", `attempt-${attempt}`),
439
+ hide: privateEnvNames(config),
440
+ })
441
+ // The failing element on the test's page and on the replay's: read from saved snapshots, not a live browser.
442
+ const files = { log: run.log, pageSnapshot: run.pageSnapshot, context: run.ok ? [] : failureContext(run.output, run.pageSnapshot, replaySnapshots()) }
443
+ session.resetStall?.()
444
+ result.output = run.output
445
+ result.browsers = run.browsers
446
+ if (opts.signal?.aborted) {
447
+ result.status = "stopped"
448
+ break
449
+ }
450
+ const secs = Math.round(run.ms / 1000)
451
+ if (run.ok) lastFailure = ""
452
+ if (tidying && run.ok && !run.browsers.length && !report.remote_browser) {
453
+ finishWithKept(attempt, "after the coding-standard fix the test opened no browser")
454
+ break
455
+ }
456
+ if (run.ok && !run.browsers.length && !report.remote_browser) {
457
+ emit({ type: "code", kind: "test", ok: false, attempt, text: `${report.test_file} exited 0 in ${secs}s but opened no browser (attempt ${attempt}/${maxAttempts})` })
458
+ attemptLog.push(attemptLine(attempt, "exited 0 but opened no browser", "", report.notes))
459
+ nextTask = noBrowserPrompt(report.command)
460
+ continue
461
+ }
462
+ if (run.ok) {
463
+ const where = run.browsers.length ? run.browsers.join(", ") : `remote browser (${report.remote_browser})`
464
+ emit({ type: "code", kind: "info", text: `${report.test_file} passed in ${secs}s in ${where}; a reviewer checks it against the goal` })
465
+ const changes = jobChanges(before) ?? undefined
466
+ // After a rejection or a tidy-up, the reviewer checks its own earlier issues instead of starting over.
467
+ const verdict = await reviewer.review({ handoff, report, output: run.output, browsers: run.browsers, seconds: secs, changes, before: preexisting(before), ...(result.review ? { earlier: result.review } : {}) })
468
+ if (opts.signal?.aborted) {
469
+ result.status = "stopped"
470
+ break
471
+ }
472
+ if (!verdict.review) {
473
+ result.notes = `The test passed, but the review did not finish: ${verdict.error}`
474
+ emit({ type: "code", kind: "test", ok: false, attempt, text: result.notes })
475
+ break
476
+ }
477
+ result.review = verdict.review
478
+ const correctness = verdict.review.issues.filter((i) => i.kind !== "standards")
479
+ const standards = verdict.review.issues.filter((i) => i.kind === "standards")
480
+ // Coding standards alone never fail a test that works and tests the goal.
481
+ if (tidying && correctness.length) {
482
+ // The tidy-up changed what the test does: back to the version that passed.
483
+ finishWithKept(attempt, `the coding-standard fix changed what the test checks (${verdict.review.summary})`)
484
+ break
485
+ }
486
+ if (verdict.review.approved || (!correctness.length && standards.length)) {
487
+ if (standards.length && !tidying && config.code.tidy !== false) {
488
+ tidy = { files: snapshotFiles(repo, jobChanges(before) || result.changed), report: { ...report }, review: verdict.review, output: run.output, browsers: run.browsers }
489
+ tidying = true
490
+ allowed = Math.max(allowed, attempt + 1)
491
+ emit({ type: "code", kind: "test", ok: true, attempt, text: `${report.test_file} passed and tests the goal; the reviewer found ${standards.length} coding-standard ${standards.length === 1 ? "issue" : "issues"}, so the agent fixes ${standards.length === 1 ? "it" : "them"} in one more round (the passing version is kept if that breaks it)` })
492
+ attemptLog.push(attemptLine(attempt, "passed and tests the goal; coding-standard issues to fix", standards.map((i) => i.problem).join(" "), report.notes))
493
+ nextTask = standardsFixPrompt(standards)
494
+ continue
495
+ }
496
+ result.status = "passed"
497
+ if (standards.length) {
498
+ result.review = { ...verdict.review, approved: true, standardsLeft: true }
499
+ result.notes = `The test passes and tests the goal. Coding standards still to fix: ${standards.map((i) => `${i.file ? `${i.file}: ` : ""}${i.problem}`).join(" ")}`
500
+ } else result.review = { ...verdict.review, approved: true }
501
+ if (notesDir) addWrittenTest(notesDir, repo, { goal: handoff.goal, test_file: report.test_file, command: report.command, changed: result.changed })
502
+ emit({ type: "code", kind: "test", ok: true, attempt, text: `${report.test_file} passed and was approved (attempt ${attempt}/${allowed})${standards.length ? `; ${standards.length} coding-standard ${standards.length === 1 ? "issue" : "issues"} left in the notes` : ""}` })
503
+ break
504
+ }
505
+ const n = verdict.review.issues.length
506
+ emit({ type: "code", kind: "test", ok: false, attempt, text: `review found ${n} ${n === 1 ? "issue" : "issues"}: ${verdict.review.summary} (attempt ${attempt}/${maxAttempts})` })
507
+ // A second rejection usually means the goal and the page disagree (a test case the page cannot satisfy), not a
508
+ // fix the next attempt will find: stop and report the issues instead of spending the remaining attempts.
509
+ if (++rejections >= MAX_REJECTIONS) {
510
+ result.notes = `Stopped: the test passes, but the reviewer did not approve it ${rejections} times. Latest review: ${verdict.review.summary}`
511
+ emit({ type: "code", kind: "test", ok: false, attempt, text: `stopped: the reviewer did not approve the test ${rejections} times; its issues are in the result` })
512
+ break
513
+ }
514
+ attemptLog.push(attemptLine(attempt, "passed; the reviewer asked for changes", verdict.review.summary, report.notes))
515
+ nextTask = reviewFixPrompt(verdict.review.summary, verdict.review.issues)
516
+ continue
517
+ }
518
+ if (tidying) {
519
+ finishWithKept(attempt, `the coding-standard fix made the test fail (${run.exitCode === null ? "timed out" : `exit ${run.exitCode}`})`)
520
+ break
521
+ }
522
+ const why = run.exitCode === null ? `timed out after ${config.code.testTimeoutSeconds}s` : `exit ${run.exitCode}`
523
+ const mismatch = browserMismatch(runBrowser, run.browsers, report.command)
524
+ if (mismatch) emit({ type: "code", kind: "info", text: `The test's browser differs from the run's: ${run.browsers.join(", ")}` })
525
+ const key = failureKey(run.output)
526
+ sameFailures = key && key === lastFailure ? sameFailures + 1 : 0
527
+ lastFailure = key
528
+ if (sameFailures >= 2) {
529
+ result.notes = `Stopped: the test failed the same way ${sameFailures + 1} times in a row (apart from timings and line numbers), again after the agent was asked to look at the failing step first. Last output:\n${run.output.split("\n").slice(-40).join("\n")}`
530
+ emit({ type: "code", kind: "test", ok: false, attempt, text: `stopped: ${report.test_file} failed the same way ${sameFailures + 1} times in a row` })
531
+ break
532
+ }
533
+ if (sameFailures === 1) {
534
+ emit({ type: "code", kind: "test", ok: false, attempt, text: `${report.test_file} failed the same way as the last attempt, ${why} (attempt ${attempt}/${maxAttempts})` })
535
+ attemptLog.push(attemptLine(attempt, `failed, ${why}, the same way as before`, firstError(run.output), report.notes))
536
+ nextTask = repeatedFailurePrompt(sameFailures + 1, report.command, run.exitCode, run.output, files) + mismatch + switchLiveOn()
537
+ continue
538
+ }
539
+ emit({ type: "code", kind: "test", ok: false, attempt, text: `${report.test_file} failed, ${why} (attempt ${attempt}/${maxAttempts})` })
540
+ attemptLog.push(attemptLine(attempt, `failed, ${why}`, firstError(run.output), report.notes))
541
+ nextTask = fixPrompt(attempt, report.command, run.exitCode, run.output, files) + mismatch
542
+ }
543
+ keepSession = result.status === "stopped" || runtimeFailed
544
+ if (report.test_name) result.testName = report.test_name
545
+ if (opts.knownDefect) result.knownDefect = true
546
+ const tc = opts.testCase
547
+ if (result.status === "passed" && tc) {
548
+ recordMapping(repo, {
549
+ caseId: tc.id,
550
+ title: tc.title,
551
+ source: tc.source,
552
+ ...(tc.url ? { url: tc.url } : {}),
553
+ status: opts.knownDefect ? "known_defect" : "automated",
554
+ ...(result.testFile ? { testFile: result.testFile } : {}),
555
+ ...(result.testName ? { testName: result.testName } : {}),
556
+ ...(result.command ? { command: result.command } : {}),
557
+ ...(opts.knownDefect?.bug ? { bug: opts.knownDefect.bug } : {}),
558
+ })
559
+ }
560
+ // An unfinished job (not one that can be resumed) leaves no failing test among the tests: it goes to needs-attention.
561
+ if ((result.status === "failed" || result.status === "blocked") && !keepSession && config.code.needsAttention !== false) {
562
+ const attention = moveToAttention({
563
+ repo,
564
+ tree: before,
565
+ testFile: result.testFile,
566
+ command: result.command,
567
+ caseId: tc?.id,
568
+ title: tc?.title,
569
+ url: tc?.url,
570
+ why: result.notes || `The test did not pass after ${result.attempts} attempts.`,
571
+ output: result.output,
572
+ review: result.review,
573
+ })
574
+ if (attention) {
575
+ result.attention = attention
576
+ emit({ type: "code", kind: "info", text: `The unfinished test was moved to ${attention.dir}/ with a README on why and how to pick it up${attention.patch ? "; its edits to existing files are in changes.patch" : ""}` })
577
+ if (tc) recordMapping(repo, { caseId: tc.id, title: tc.title, source: tc.source, ...(tc.url ? { url: tc.url } : {}), status: "needs_attention", attention: attention.dir, ...(result.command ? { command: result.command } : {}) })
578
+ }
579
+ }
580
+ } finally {
581
+ if (session) {
582
+ result.usage = addUsage(spent, session.usage)
583
+ if (session.skillsUsed?.length) result.skills = [...session.skillsUsed]
584
+ await session.close()
585
+ }
586
+ await reviewer.close()
587
+ // With code.model the agent runtime picks the model, so there is no price for it in llms.json; the reviewer is
588
+ // priced at its own profile (code.review_profile). The job's cost is unknown when either part has no price.
589
+ const scribeCost = costOf(result.usage, config.code.model ? undefined : config.llm.price)
590
+ const reviewCost = reviewer.usage.input || reviewer.usage.output ? costOf(reviewer.usage, reviewer.price) : undefined
591
+ const reviewed = Boolean(reviewer.usage.input || reviewer.usage.output)
592
+ result.usage = addUsage(result.usage, reviewer.usage)
593
+ result.cost = scribeCost && (!reviewed || reviewCost) ? addCost(scribeCost, reviewCost) : undefined
594
+ if (!keepSession) rmSync(path.join(opts.runDir, SESSION_FILE), { force: true })
595
+ try {
596
+ mkdirSync(opts.runDir, { recursive: true })
597
+ writeFileSync(path.join(opts.runDir, "code.json"), `${JSON.stringify(result, null, 2)}\n`)
598
+ } catch (e) {
599
+ emit({ type: "code", kind: "info", text: `Could not save code.json: ${(e as Error).message}` })
600
+ }
601
+ }
602
+ return result
603
+ }