@capagents/forgeqe 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/HOW_TO_USE.md +54 -3
  2. package/README.md +1 -1
  3. package/docs/COST_ARCHITECTURE.md +280 -0
  4. package/docs/feature-test-prompts.md +95 -0
  5. package/docs/user-guide.html +66 -8
  6. package/forgeqe.example.yaml +15 -0
  7. package/package.json +1 -1
  8. package/prompts/brain.md +36 -4
  9. package/prompts/browser-api.md +13 -0
  10. package/prompts/browser-capture.md +2 -2
  11. package/prompts/browser-checks.md +2 -0
  12. package/py/forgeqe_engine/api_step.py +130 -0
  13. package/py/forgeqe_engine/capture.py +53 -2
  14. package/py/forgeqe_engine/checks.py +8 -2
  15. package/py/forgeqe_engine/llm.py +33 -5
  16. package/py/forgeqe_engine/memory.py +70 -2
  17. package/py/forgeqe_engine/observe.py +123 -0
  18. package/py/forgeqe_engine/replay.py +55 -2
  19. package/py/forgeqe_engine/service.py +177 -5
  20. package/src/api/fromSteps.ts +111 -0
  21. package/src/capabilities/registry.ts +19 -5
  22. package/src/cases/ado.ts +1 -1
  23. package/src/cases/journey.ts +61 -0
  24. package/src/cases/quality.ts +3 -1
  25. package/src/cases/readiness.ts +35 -6
  26. package/src/cases/save.ts +241 -48
  27. package/src/cases/types.ts +11 -0
  28. package/src/cli/help.ts +8 -0
  29. package/src/cli/index.ts +64 -1
  30. package/src/code/coverage.ts +22 -3
  31. package/src/code/decisions.ts +73 -0
  32. package/src/code/find.ts +3 -1
  33. package/src/code/graph/graph.ts +33 -2
  34. package/src/code/handoff.ts +20 -0
  35. package/src/code/loop.ts +27 -1
  36. package/src/code/notes.ts +48 -1
  37. package/src/code/prompt.ts +31 -9
  38. package/src/code/protect.ts +103 -0
  39. package/src/code/replay.ts +33 -0
  40. package/src/code/review.ts +2 -0
  41. package/src/code/runtime.ts +35 -3
  42. package/src/code/shortlist.ts +309 -0
  43. package/src/code/testrun.ts +3 -1
  44. package/src/code/traceability.ts +2 -0
  45. package/src/commands.ts +6 -1
  46. package/src/config.ts +46 -0
  47. package/src/engine/bridge.ts +47 -5
  48. package/src/mcp/casetools.ts +107 -11
  49. package/src/mcp/commands.ts +74 -20
  50. package/src/mcp/packtools.ts +187 -0
  51. package/src/mcp/tools.ts +238 -29
  52. package/src/pack/collect.ts +251 -0
  53. package/src/pack/index.ts +134 -0
  54. package/src/pack/init.ts +45 -0
  55. package/src/pack/pack.ts +327 -0
  56. package/src/pack/pipeline.ts +373 -0
  57. package/src/pack/run.ts +155 -0
  58. package/src/registry/db.ts +249 -0
  59. package/src/registry/index.ts +72 -0
  60. package/src/registry/resolve.ts +57 -0
  61. package/src/registry/tier0.ts +81 -0
  62. package/src/report/data.ts +7 -0
  63. package/src/report/html.ts +14 -0
  64. package/src/report/template.html +52 -48
  65. package/src/report/usage.ts +113 -0
  66. package/src/run.ts +32 -2
  67. package/src/suite/report.ts +22 -4
  68. package/src/suite/run.ts +13 -5
  69. package/src/summary.ts +6 -0
  70. package/src/tokenomics/budget.ts +172 -0
  71. package/src/tokenomics/ledger.ts +287 -0
  72. package/src/tui/App.tsx +90 -2
  73. package/src/tui/inspector.tsx +40 -35
  74. package/src/tui/transcript.tsx +5 -4
  75. package/src/types.ts +11 -2
@@ -0,0 +1,113 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import type { BudgetConfig } from "../tokenomics/budget.ts"
5
+ import { readLedger, sessionCalls, type CallRecord } from "../tokenomics/ledger.ts"
6
+
7
+ /**
8
+ * What a report says about model usage: the report's own calls, one by one, and the last days of the ledger
9
+ * summed by day and by what the Breakdown page groups by, so a report shows this run and the history around it.
10
+ * Counts, names and times only: the ledger has no prompt or response text to carry.
11
+ */
12
+
13
+ export type UsageCall = Omit<CallRecord, "v" | "proc">
14
+
15
+ /** One day's calls with the same agent, job, purpose, model, user, machine and trigger, summed. */
16
+ export type UsageRollup = {
17
+ day: string
18
+ agent: string
19
+ job: string
20
+ purpose: string
21
+ model: string
22
+ user: string
23
+ machine: string
24
+ trigger: string
25
+ calls: number
26
+ input: number
27
+ cached: number
28
+ cacheWrite: number
29
+ output: number
30
+ /** Cost of the priced calls, in `currency`; calls in another currency are left out of the sum. */
31
+ amount: number
32
+ currency?: string
33
+ unpriced: number
34
+ errors: number
35
+ }
36
+
37
+ export type UsageData = {
38
+ calls: UsageCall[]
39
+ history: UsageRollup[]
40
+ historyDays: number
41
+ budget?: BudgetConfig
42
+ }
43
+
44
+ export const HISTORY_DAYS = 30
45
+
46
+ const strip = ({ v: _v, proc: _p, ...c }: CallRecord): UsageCall => c
47
+
48
+ /** usage.json in a run folder: its calls, or none when it was not written (a run before the ledger). */
49
+ export function readRunUsage(runDir?: string): CallRecord[] {
50
+ const file = runDir ? path.join(runDir, "usage.json") : ""
51
+ if (!file || !existsSync(file)) return []
52
+ try {
53
+ const calls = (JSON.parse(readFileSync(file, "utf8")) as { calls?: CallRecord[] }).calls
54
+ return Array.isArray(calls) ? calls : []
55
+ } catch {
56
+ return []
57
+ }
58
+ }
59
+
60
+ /** The ledger's last `days` days, summed per day and grouping; most recent day first. */
61
+ export function usageHistory(days = HISTORY_DAYS, opts: { dir?: string; now?: Date } = {}): UsageRollup[] {
62
+ const now = opts.now || new Date()
63
+ const since = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - (days - 1)))
64
+ const sums = new Map<string, UsageRollup>()
65
+ const currencies = new Map<string, number>()
66
+ const calls = readLedger({ since, dir: opts.dir })
67
+ for (const c of calls) if (c.currency) currencies.set(c.currency, (currencies.get(c.currency) || 0) + 1)
68
+ // One currency is summed: the one most calls were priced in.
69
+ const currency = [...currencies.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]
70
+ for (const c of calls) {
71
+ const day = c.ts.slice(0, 10)
72
+ const key = [day, c.agent, c.job, c.purpose, c.model, c.user, c.machine, c.trigger].join("\u0000")
73
+ const r =
74
+ sums.get(key) ||
75
+ ({ day, agent: c.agent, job: c.job, purpose: c.purpose, model: c.model, user: c.user, machine: c.machine, trigger: c.trigger, calls: 0, input: 0, cached: 0, cacheWrite: 0, output: 0, amount: 0, ...(currency ? { currency } : {}), unpriced: 0, errors: 0 } as UsageRollup)
76
+ r.calls++
77
+ r.input += c.input
78
+ r.cached += c.cached || 0
79
+ r.cacheWrite += c.cacheWrite || 0
80
+ r.output += c.output
81
+ if (c.amount !== undefined && c.currency === currency) r.amount += c.amount
82
+ else r.unpriced++
83
+ if (c.outcome === "error") r.errors++
84
+ sums.set(key, r)
85
+ }
86
+ return [...sums.values()].sort((a, b) => b.day.localeCompare(a.day) || b.amount - a.amount)
87
+ }
88
+
89
+ /**
90
+ * A report's usage. A run: the calls of its folder. A suite: the calls of each case's run folder, and the calls this
91
+ * process made while the suite ran (ForgeQE's agent, Scribe, heals), each once. Undefined when there are none.
92
+ */
93
+ export function reportUsage(p: { kind: "suite" | "run"; dir: string; startedAt: string; runDirs: string[]; budget?: BudgetConfig }, opts: { historyDays?: number; dir?: string } = {}): UsageData | undefined {
94
+ const seen = new Set<string>()
95
+ const calls: CallRecord[] = []
96
+ const add = (c: CallRecord) => {
97
+ if (!c?.id || seen.has(c.id)) return
98
+ seen.add(c.id)
99
+ calls.push(c)
100
+ }
101
+ const dirs = p.kind === "run" ? [p.dir, ...p.runDirs] : p.runDirs
102
+ for (const d of new Set(dirs)) for (const c of readRunUsage(d)) add(c)
103
+ if (p.kind === "run") for (const c of sessionCalls((c) => c.runId === path.basename(p.dir))) add(c)
104
+ if (p.kind === "suite") for (const c of sessionCalls((c) => c.ts >= p.startedAt)) add(c)
105
+ const history = usageHistory(opts.historyDays ?? HISTORY_DAYS, { dir: opts.dir })
106
+ if (!calls.length && !history.length) return undefined
107
+ return {
108
+ calls: calls.sort((a, b) => a.ts.localeCompare(b.ts)).map(strip),
109
+ history,
110
+ historyDays: opts.historyDays ?? HISTORY_DAYS,
111
+ ...(p.budget ? { budget: p.budget } : {}),
112
+ }
113
+ }
package/src/run.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { saveApiSteps } from "./api/fromSteps.ts"
1
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
3
  import { createRequire } from "node:module"
3
4
  import path from "node:path"
@@ -12,6 +13,7 @@ import { ensureEngine } from "./engine/setup.ts"
12
13
  import { reportEnv, writeRunReport } from "./report/html.ts"
13
14
  import { logEvent } from "./sessionLog.ts"
14
15
  import { costOf } from "./usage.ts"
16
+ import { writeRunUsage } from "./tokenomics/ledger.ts"
15
17
  import { allLogins, redactSecrets } from "./sessionLogins.ts"
16
18
  import { browserSkills, rememberChoice } from "./skills/select.ts"
17
19
  import { axeTags, DEFAULT_QUALITY, emptyReport, evaluateQuality, readA11y } from "./quality/budgets.ts"
@@ -19,10 +21,13 @@ import { historyFile, recordQuality } from "./quality/history.ts"
19
21
  import { compareVisual, engineVisual } from "./quality/visual.ts"
20
22
  import { enginePrompts, prompt } from "./prompts.ts"
21
23
  import { knowledgeBlock, readKnowledge, relevantKnowledge } from "./knowledge.ts"
24
+ import { registryMode } from "./registry/index.ts"
22
25
 
23
26
  export type ExecuteOptions = {
24
27
  config: AppConfig
25
28
  goal: string
29
+ /** The test case the run is for: its API steps are saved as that case's scenario. */
30
+ caseRef?: { caseId?: string; caseTitle?: string }
26
31
  browserProvider?: BrowserProvider
27
32
  /** A named device from the provider files (browser_devices); it also picks the provider. */
28
33
  browserDevice?: string
@@ -36,6 +41,8 @@ export type ExecuteOptions = {
36
41
  memory?: boolean
37
42
  /** Steps in the test case this goal came from; the agent then reports a verdict for each. */
38
43
  caseSteps?: number
44
+ /** Each case step's stepKey: after the case is edited, the engine replays the steps before the first one that changed. */
45
+ caseStepKeys?: string[]
39
46
  /** A failed or cut-off run's folder (or its name under the runs folder): replay the steps that worked there first. */
40
47
  resumeFrom?: string
41
48
  /** Browser skills the caller chose (empty: none); unset: the engine picks the ones the goal needs. */
@@ -131,6 +138,16 @@ function shortPage(url: string): string {
131
138
  }
132
139
  }
133
140
 
141
+ /**
142
+ * Page memory for the engine, or undefined to run without it. registry.mode on (the default): a replay may also be
143
+ * passed by its recorded checks, with no model (docs/COST_ARCHITECTURE.md); off or shadow: as it always was.
144
+ */
145
+ export function engineMemory(opts: Pick<ExecuteOptions, "config" | "replay" | "memory">): { dir: string; replay: ReplayMode; checks_first?: boolean } | undefined {
146
+ const mem = opts.config.memory
147
+ if (!mem.enabled || opts.memory === false) return undefined
148
+ return { dir: mem.dir, replay: opts.replay || mem.replay, ...(registryMode(opts.config) === "on" ? { checks_first: true } : {}) }
149
+ }
150
+
134
151
  export async function executeRun(opts: ExecuteOptions): Promise<{ result: RunResult; files: string[] }> {
135
152
  const goal = redactSecrets(opts.goal.trim())
136
153
  if (!goal) throw new Error("A goal is required. Pass --goal or type one in the shell.")
@@ -156,6 +173,19 @@ export async function executeRun(opts: ExecuteOptions): Promise<{ result: RunRes
156
173
  const withReport = (out: { result: RunResult; files: string[] }) => {
157
174
  out.result.cost = costOf(out.result.usage, opts.config.llm.price)
158
175
  applyQuality(opts.config, dir, out.result, opts.onEvent)
176
+ // API steps the run made in the browser's session, kept as a scenario the API tools run again without a browser.
177
+ try {
178
+ const api = saveApiSteps(opts.config, dir, opts.caseRef || {})
179
+ if (api) out.result.api = { id: api.id, dir: api.dir, calls: api.calls }
180
+ } catch (err) {
181
+ opts.onEvent?.({ type: "log", level: "warn", text: `API steps not saved as a scenario: ${(err as Error).message}` })
182
+ }
183
+ try {
184
+ const usage = writeRunUsage(dir)
185
+ if (usage) out.files.push(usage)
186
+ } catch (err) {
187
+ opts.onEvent?.({ type: "log", level: "warn", text: `usage.json not written: ${(err as Error).message}` })
188
+ }
159
189
  logEvent("run.done", { out_dir: dir, status: out.result.status, summary: out.result.summary, usage: out.result.usage, memory: out.result.memory, ...(out.result.skills ? { skills: out.result.skills } : {}) })
160
190
  try {
161
191
  out.files.push(writeRunReport(dir, out.result, reportEnv(opts.config, { url: startUrl, headed: opts.headed, browser }), t0))
@@ -203,8 +233,7 @@ export async function executeRun(opts: ExecuteOptions): Promise<{ result: RunRes
203
233
  BROWSER_USE_USE_JUDGE: opts.config.browser.judge ? "true" : "false",
204
234
  }
205
235
  for (const [k, v] of Object.entries(tuning)) if (!process.env[k]) env[k] = v
206
- const mem = opts.config.memory
207
- const memory = mem.enabled && opts.memory !== false ? { dir: mem.dir, replay: opts.replay || mem.replay } : undefined
236
+ const memory = engineMemory(opts)
208
237
  const resume = opts.resumeFrom ? loadResume(opts.config.export.outDir, opts.resumeFrom) : undefined
209
238
  if (resume && !memory) throw new Error("resume_from replays remembered steps, so it needs page memory; it is off for this run.")
210
239
  const q = opts.config.quality || DEFAULT_QUALITY
@@ -231,6 +260,7 @@ export async function executeRun(opts: ExecuteOptions): Promise<{ result: RunRes
231
260
  env,
232
261
  logins: allLogins(opts.config),
233
262
  caseSteps: opts.caseSteps,
263
+ ...(opts.caseRef?.caseId && opts.caseStepKeys?.length ? { caseId: opts.caseRef.caseId, caseStepKeys: opts.caseStepKeys } : {}),
234
264
  screenshots: opts.config.report.screenshots,
235
265
  accessibility: opts.config.report.accessibility,
236
266
  network: opts.config.api?.capture !== false,
@@ -77,11 +77,28 @@ export function suiteUsage(results: CaseResult[], analysis?: { usage: Usage; cos
77
77
  return { usage, cost: priced ? cost : undefined }
78
78
  }
79
79
 
80
+ /**
81
+ * The suite's headline, saying what each part did: the cases, and the tests written from them. A suite whose cases
82
+ * all passed but whose test did not pass is not called failed with "1/1 passed" beside it.
83
+ */
84
+ export function suiteHeadline(s: Pick<SuiteResult, "results" | "stopped">): { mark: "✓" | "✗" | "■"; title: string; counts: string; ok: boolean } {
85
+ const c = counts(s.results)
86
+ const coded = s.results.filter((r) => r.code)
87
+ const testsOk = coded.filter((r) => r.code!.status === "passed").length
88
+ const casesOk = c.passed === c.total
89
+ const testsLine = coded.length ? ` · ${testsOk}/${coded.length} ${coded.length === 1 ? "test" : "tests"} passing` : ""
90
+ const countsLine = `${c.passed}/${c.total} ${c.total === 1 ? "case" : "cases"} passed${testsLine}`
91
+ if (s.stopped) return { mark: "■", title: "Suite stopped", counts: countsLine, ok: false }
92
+ if (!casesOk) return { mark: "✗", title: "Suite failed", counts: countsLine, ok: false }
93
+ if (testsOk < coded.length) return { mark: "✗", title: `${c.total === 1 ? "Case" : "Cases"} passed, ${coded.length - testsOk === 1 ? "test" : "tests"} not passing`, counts: countsLine, ok: false }
94
+ return { mark: "✓", title: "Suite passed", counts: countsLine, ok: true }
95
+ }
96
+
80
97
  /** The end-of-suite summary for the terminal. */
81
98
  export function formatSuite(s: SuiteResult, cwd = process.cwd()): string {
82
- const c = counts(s.results)
83
99
  const tokens = s.results.reduce((n, r) => n + r.tokens, 0)
84
- const head = `${s.stopped ? "■ Suite stopped" : c.passed === c.total ? "✓ Suite passed" : "✗ Suite failed"} · ${c.passed}/${c.total} passed · ${duration(s.ms)} · ${(tokens / 1000).toFixed(1)}K tokens`
100
+ const h = suiteHeadline(s)
101
+ const head = `${h.mark} ${h.title} · ${h.counts} · ${duration(s.ms)} · ${(tokens / 1000).toFixed(1)}K tokens`
85
102
  const show = (f: string) => path.relative(cwd, f) || f
86
103
  const total = suiteUsage(s.results, s.analysis)
87
104
  const lines = [head, ""]
@@ -116,7 +133,8 @@ export function formatSuite(s: SuiteResult, cwd = process.cwd()): string {
116
133
  export function markdownSummary(s: SuiteResult): string {
117
134
  const c = counts(s.results)
118
135
  const cell = (t: string) => t.replace(/\|/g, "\\|").replace(/\s+/g, " ").trim()
119
- const head = s.stopped ? "■ Suite stopped" : c.passed === c.total ? "✓ Suite passed" : "✗ Suite failed"
136
+ const h = suiteHeadline(s)
137
+ const head = `${h.mark} ${h.title}`
120
138
  const rows = s.results.map((r) => {
121
139
  const test = r.code ? `${r.code.status}${r.code.testFile ? ` \`${cell(r.code.testFile)}\`` : ""}` : ""
122
140
  const steps = r.stepResults?.length && r.case.steps.length ? `${r.stepResults.filter((v) => v.status === "passed").length}/${r.case.steps.length}` : ""
@@ -140,7 +158,7 @@ export function markdownSummary(s: SuiteResult): string {
140
158
  ]
141
159
  : []
142
160
  return [
143
- `### ${head} · ${c.passed}/${c.total} passed · ${duration(s.ms)}`,
161
+ `### ${head} · ${h.counts} · ${duration(s.ms)}`,
144
162
  "",
145
163
  "| | Case | Title | Steps | Time | Result | Test |",
146
164
  "|---|---|---|---|---|---|---|",
package/src/suite/run.ts CHANGED
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
2
2
  import path from "node:path"
3
3
 
4
4
  import { goalFor, writeBack as postResults, type CaseResult, type TestCase, type WriteBack } from "../cases/index.ts"
5
- import { costOf, type Cost } from "../cases/types.ts"
5
+ import { costOf, stepKey, stepsHash, type Cost } from "../cases/types.ts"
6
6
  import { addUsage, NO_USAGE } from "../usage.ts"
7
7
  import type { AgentEvent, CodeResult, ReplayMode, RunResult, Usage } from "../types.ts"
8
8
  import { codeSkipReason, runCodeAgent } from "../code/index.ts"
@@ -15,7 +15,7 @@ import { reproPack, writeReproFiles } from "../cases/repro.ts"
15
15
  import { commentAnalyses } from "../cases/bugs.ts"
16
16
  import { caseOutcomes, defectHistoryFile, failedInARow, noteHistory, readDefectHistory, recordRun, writeDefectHistory } from "../cases/defectHistory.ts"
17
17
  import { classifyFailure, FAILURE_LABEL } from "../cases/failure.ts"
18
- import { associateAdoAutomation, readMappings } from "../code/traceability.ts"
18
+ import { associateAdoAutomation, readMappings, recordMapping } from "../code/traceability.ts"
19
19
  import type { ExistingTest } from "../code/prompt.ts"
20
20
  import { runTestCommand } from "../code/testrun.ts"
21
21
  import { privateEnvNames } from "../childEnv.ts"
@@ -151,6 +151,7 @@ export async function runSuite(opts: SuiteOptions): Promise<SuiteResult> {
151
151
  const { result } = await execute({
152
152
  config,
153
153
  goal: goalFor(original),
154
+ caseRef: { caseId: original.id, caseTitle: original.title },
154
155
  browserProvider: opts.browserProvider,
155
156
  browserDevice: device,
156
157
  url: tc.startUrl || opts.url,
@@ -159,6 +160,7 @@ export async function runSuite(opts: SuiteOptions): Promise<SuiteResult> {
159
160
  replay: opts.replay,
160
161
  memory: opts.memory,
161
162
  caseSteps: tc.steps.length,
163
+ caseStepKeys: original.steps.map(stepKey),
162
164
  outDir,
163
165
  skills: opts.skills,
164
166
  repo: opts.skillsRepo || opts.repo,
@@ -263,16 +265,20 @@ export async function runSuite(opts: SuiteOptions): Promise<SuiteResult> {
263
265
  const run = await (opts.runTest || runTestCommand)({ command: mapped.command, cwd: opts.repo!, timeoutSeconds: config.code.testTimeoutSeconds, signal: opts.signal, hide: privateEnvNames(config), save: path.join(runDir, "existing-test") })
264
266
  const defectNow = result.failure?.type === "functional_defect"
265
267
  // Reused as it is: a normal test that passes on a case that passes, or a known-defect test (an expected failure)
266
- // on a case that still shows the defect.
268
+ // on a case that still shows the defect, as long as the case's steps are the ones the test was written from.
267
269
  const fits = mapped.status === "known_defect" ? defectNow : !defectNow
268
- if (run.ok && fits) {
270
+ const hash = tc.steps.length ? stepsHash(tc.steps) : undefined
271
+ const changed = Boolean(mapped.stepsHash && hash && mapped.stepsHash !== hash)
272
+ if (run.ok && fits && !changed) {
273
+ // A mapping from before steps were fingerprinted gets this case's: a later change to the steps then shows.
274
+ if (hash && !mapped.stepsHash) recordMapping(opts.repo!, { ...mapped, stepsHash: hash })
269
275
  emit({ type: "case-event", index, case: tc, event: { type: "code", kind: "test", ok: true, attempt: 0, text: `${mapped.testFile} passes as it is: no new test needed` } })
270
276
  result.code = { status: "passed", testFile: mapped.testFile, command: mapped.command, attempts: 0, existing: "reused", ...(mapped.testName ? { testName: mapped.testName } : {}), ...(mapped.status === "known_defect" ? { knownDefect: true } : {}) }
271
277
  emit({ type: "code-done", index, case: tc, result })
272
278
  return
273
279
  }
274
280
  if (opts.signal?.aborted) return
275
- const why =
281
+ const reason =
276
282
  mapped.status === "known_defect" && !defectNow
277
283
  ? `It is marked as an expected failure for a known defect${mapped.bug ? ` (${mapped.bug.label})` : ""}, and the browser run now passes: the defect looks fixed. Remove the expected-failure marker and keep the assertions.`
278
284
  : defectNow && run.ok
@@ -280,6 +286,8 @@ export async function runSuite(opts: SuiteOptions): Promise<SuiteResult> {
280
286
  : defectNow
281
287
  ? "It fails because of the functional defect this run found (below): keep its assertions and mark it as an expected failure naming the defect."
282
288
  : "It fails now: the application or the page changed since it was written."
289
+ const stale = "The test case's steps changed since this test was written (for example after a review): make the test do the case's steps and check its expected results as they are now, keeping the parts that still fit."
290
+ const why = !changed ? reason : run.ok && fits ? stale : `${stale} ${reason}`
283
291
  existingTest = { testFile: mapped.testFile, command: mapped.command, output: run.output, why }
284
292
  }
285
293
  emit({ type: "code-start", index, case: tc })
package/src/summary.ts CHANGED
@@ -25,6 +25,8 @@ export type RunSummary = {
25
25
  usage: string
26
26
  /** WCAG rules passed and failed, Core Web Vitals, and whether the budgets held. */
27
27
  quality?: string
28
+ /** The API steps saved as a reusable scenario: "2 calls saved as API scenario 20261013-…" and where. */
29
+ api?: string
28
30
  }
29
31
 
30
32
  export const STATUS_TITLE: Record<RunStatus, string> = {
@@ -60,6 +62,7 @@ export function memoryLine(m?: MemoryInfo): string {
60
62
  parts.push(m.replayed === m.replayTotal ? `replayed all ${m.replayed} steps` : `replayed ${m.replayed} of ${m.replayTotal} steps`)
61
63
  parts.push(plural(m.llmSteps, "LLM step"))
62
64
  }
65
+ if (m.checked) parts.push(m.checksHeld === m.checked ? `${plural(m.checked, "recorded check")} held` : `${m.checksHeld || 0} of ${plural(m.checked, "recorded check")} held`)
63
66
  if (m.elements) parts.push(`${plural(m.elements, "element")} on ${plural(m.pages, "page")}${m.newElements ? ` (${m.newElements} new)` : ""}`)
64
67
  if (m.flow) parts.push(m.flow === "saved" ? "flow saved for replay" : "flow updated")
65
68
  return parts.join(" · ")
@@ -80,6 +83,7 @@ export function buildSummary(opts: {
80
83
  failedChecks?: { expected: string; actual: string }[]
81
84
  quality?: QualityReport
82
85
  budgetExceeded?: string[]
86
+ api?: { id: string; dir: string; calls: number }
83
87
  }): RunSummary {
84
88
  const dir = opts.files.length ? path.relative(process.cwd(), path.dirname(opts.files[0])) || "." : ""
85
89
  const assertion = opts.outcome === "assertion_failed"
@@ -97,6 +101,7 @@ export function buildSummary(opts: {
97
101
  savedFiles: opts.files.map((f) => path.basename(f)),
98
102
  usage: opts.usage ? usageLine(opts.usage, opts.cost) : "",
99
103
  ...(opts.quality && qualityLine(opts.quality) ? { quality: qualityLine(opts.quality) } : {}),
104
+ ...(opts.api ? { api: `${opts.api.calls} ${opts.api.calls === 1 ? "call" : "calls"} saved as a reusable API scenario · ${path.relative(process.cwd(), opts.api.dir) || opts.api.dir}/` } : {}),
100
105
  }
101
106
  }
102
107
 
@@ -106,6 +111,7 @@ export function formatSummary(s: RunSummary): string {
106
111
  if (s.finalUrl) lines.push(`Ended on ${s.finalUrl}`)
107
112
  if (s.memory) lines.push(`Memory ${s.memory}`)
108
113
  if (s.quality) lines.push(`Quality ${s.quality}`)
114
+ if (s.api) lines.push(`API ${s.api}`)
109
115
  if (s.usage) lines.push(`Usage ${s.usage}`)
110
116
  if (s.savedDir) lines.push(`Saved ${s.savedDir}/ ${s.savedFiles.join(" · ")}`)
111
117
  return lines.join("\n")
@@ -0,0 +1,172 @@
1
+ import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import { formatCost } from "../usage.ts"
5
+ import { onCall, usageDir, type CallRecord } from "./ledger.ts"
6
+
7
+ /**
8
+ * Spending budgets (budget: in forgeqe.yaml), per run, day and month, in the price currency. They only warn, once
9
+ * each at 80%, 90% and 100%, and never stop a run. Spend is read from the ledger file, which every ForgeQE process
10
+ * on this machine appends to: a browser run the agent starts through the MCP server runs in another process, and
11
+ * its calls count all the same. Days and months are UTC, as the ledger's timestamps are.
12
+ */
13
+
14
+ export type BudgetConfig = { run?: number; day?: number; month?: number; currency?: string }
15
+ export type BudgetPeriod = "run" | "day" | "month"
16
+
17
+ export type BudgetWarning = { period: BudgetPeriod; threshold: number; spent: number; limit: number; currency: string; text: string }
18
+
19
+ export const BUDGET_THRESHOLDS = [80, 90, 100] as const
20
+
21
+ /** How often the ledger is read again for calls other processes made. */
22
+ const POLL_MS = 5_000
23
+
24
+ /** budget: in forgeqe.yaml; amounts that are not positive numbers are left out. */
25
+ export function budgetConfig(raw: unknown): BudgetConfig | undefined {
26
+ const r = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null
27
+ if (!r) return undefined
28
+ const amount = (v: unknown) => (typeof v === "number" || (typeof v === "string" && v.trim() !== "") ? (Number(v) > 0 ? Number(v) : undefined) : undefined)
29
+ const out: BudgetConfig = {}
30
+ for (const k of ["run", "day", "month"] as const) {
31
+ const a = amount(r[k])
32
+ if (a !== undefined) out[k] = a
33
+ }
34
+ if (typeof r.currency === "string" && /^[A-Za-z]{3}$/.test(r.currency.trim())) out.currency = r.currency.trim().toUpperCase()
35
+ return out.run || out.day || out.month ? out : undefined
36
+ }
37
+
38
+ export type BudgetStatus = { period: BudgetPeriod; spent: number; limit: number; currency: string; percent: number }
39
+
40
+ /**
41
+ * Watch spend against `cfg` and call `warn` the first time each period passes each threshold. Returns the
42
+ * unsubscribe and a way to read where each budget stands.
43
+ */
44
+ export function watchBudgets(cfg: BudgetConfig, warn: (w: BudgetWarning) => void, opts: { dir?: string; now?: () => Date; pollMs?: number } = {}) {
45
+ const now = opts.now || (() => new Date())
46
+ const dir = () => opts.dir || usageDir()
47
+ const warned = new Set<string>()
48
+ const seen = new Set<string>()
49
+ let currency = cfg.currency
50
+ let month = ""
51
+ let offset = 0
52
+ let monthAmount = 0
53
+ const days = new Map<string, number>()
54
+ const runs = new Map<string, number>()
55
+ const touched = new Set<string>()
56
+
57
+ const add = (c: CallRecord) => {
58
+ if (!c?.id || seen.has(c.id) || c.amount === undefined || !c.ts?.startsWith(month)) return
59
+ seen.add(c.id)
60
+ currency ||= cfg.currency || c.currency
61
+ if (c.currency !== currency) return
62
+ monthAmount += c.amount
63
+ days.set(c.ts.slice(0, 10), (days.get(c.ts.slice(0, 10)) || 0) + c.amount)
64
+ if (c.runId) {
65
+ runs.set(c.runId, (runs.get(c.runId) || 0) + c.amount)
66
+ touched.add(c.runId)
67
+ }
68
+ }
69
+
70
+ /** Read what was appended to this month's file since the last read; a new month starts over. */
71
+ const sync = () => {
72
+ const m = now().toISOString().slice(0, 7)
73
+ if (m !== month) {
74
+ month = m
75
+ offset = 0
76
+ monthAmount = 0
77
+ days.clear()
78
+ runs.clear()
79
+ seen.clear()
80
+ }
81
+ const file = path.join(dir(), `${month}.jsonl`)
82
+ if (!existsSync(file)) return
83
+ try {
84
+ const size = statSync(file).size
85
+ if (size < offset) offset = 0
86
+ if (size === offset) return
87
+ const fd = openSync(file, "r")
88
+ const buf = Buffer.alloc(size - offset)
89
+ try {
90
+ readSync(fd, buf, 0, buf.length, offset)
91
+ } finally {
92
+ closeSync(fd)
93
+ }
94
+ // Only whole lines: a line another process is still writing is read next time.
95
+ const text = buf.toString("utf8")
96
+ const end = text.lastIndexOf("\n")
97
+ if (end < 0) return
98
+ offset += Buffer.byteLength(text.slice(0, end + 1))
99
+ for (const line of text.slice(0, end).split("\n")) {
100
+ try {
101
+ add(JSON.parse(line) as CallRecord)
102
+ } catch {}
103
+ }
104
+ } catch {}
105
+ }
106
+
107
+ const check = () => {
108
+ const day = now().toISOString().slice(0, 10)
109
+ const targets: { period: BudgetPeriod; key: string; spent: number }[] = []
110
+ if (cfg.day) targets.push({ period: "day", key: day, spent: days.get(day) || 0 })
111
+ if (cfg.month) targets.push({ period: "month", key: month, spent: monthAmount })
112
+ if (cfg.run) for (const id of touched) targets.push({ period: "run", key: id, spent: runs.get(id) || 0 })
113
+ touched.clear()
114
+ for (const { period, key, spent } of targets) {
115
+ const limit = cfg[period]!
116
+ // Only the highest threshold newly passed is said: a jump from 70% to 105% is one warning, not three.
117
+ const passed = BUDGET_THRESHOLDS.filter((t) => spent >= (limit * t) / 100 && !warned.has(`${period}:${key}:${t}`))
118
+ if (!passed.length) continue
119
+ for (const t of passed) warned.add(`${period}:${key}:${t}`)
120
+ const threshold = passed[passed.length - 1]!
121
+ const cur = currency || "USD"
122
+ warn({ period, threshold, spent, limit, currency: cur, text: warningText(period, threshold, spent, limit, cur) })
123
+ }
124
+ }
125
+
126
+ // Spend so far this month is counted, but only what passes a threshold from now on is said.
127
+ sync()
128
+ for (const [period, key, spent] of [["day", now().toISOString().slice(0, 10), days.get(now().toISOString().slice(0, 10)) || 0], ["month", month, monthAmount]] as const) {
129
+ const limit = cfg[period]
130
+ if (limit) for (const t of BUDGET_THRESHOLDS) if (spent >= (limit * t) / 100) warned.add(`${period}:${key}:${t}`)
131
+ }
132
+ touched.clear()
133
+
134
+ const stopListening = onCall((c) => {
135
+ sync()
136
+ add(c) // the ledger is off (FORGEQE_USAGE=off) or the line was not written: count it all the same
137
+ check()
138
+ })
139
+ const timer = setInterval(() => {
140
+ sync()
141
+ check()
142
+ }, opts.pollMs ?? POLL_MS)
143
+ timer.unref?.()
144
+
145
+ const stop = () => {
146
+ stopListening()
147
+ clearInterval(timer)
148
+ }
149
+ const status = (runId?: string): BudgetStatus[] => {
150
+ sync()
151
+ const day = now().toISOString().slice(0, 10)
152
+ const out: BudgetStatus[] = []
153
+ const push = (period: BudgetPeriod, spent: number) => {
154
+ const limit = cfg[period]
155
+ if (limit) out.push({ period, spent, limit, currency: currency || "USD", percent: Math.round((spent / limit) * 100) })
156
+ }
157
+ if (runId) push("run", runs.get(runId) || 0)
158
+ push("day", days.get(day) || 0)
159
+ push("month", monthAmount)
160
+ return out
161
+ }
162
+ return { stop, status }
163
+ }
164
+
165
+ const WHOSE: Record<BudgetPeriod, string> = { run: "this run's", day: "today's", month: "this month's" }
166
+
167
+ /** "Budget: this month's model spend is at $245.10, 82% of the $300.00 month budget (…). Nothing is stopped." */
168
+ export function warningText(period: BudgetPeriod, threshold: number, spent: number, limit: number, currency: string): string {
169
+ const money = (amount: number) => formatCost({ amount, currency })
170
+ const verb = threshold >= 100 ? "has reached" : "is at"
171
+ return `Budget: ${WHOSE[period]} model spend ${verb} ${money(spent)}, ${Math.round((spent / limit) * 100)}% of the ${money(limit)} ${period} budget (budget.${period} in forgeqe.yaml). Nothing is stopped.`
172
+ }