@capagents/forgeqe 1.2.1 → 1.3.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 (80) hide show
  1. package/HOW_TO_USE.md +40 -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 +54 -7
  6. package/evals/prompts/brain/route-api-scenarios.yaml +9 -0
  7. package/evals/prompts/brain/route-e2e.yaml +10 -0
  8. package/evals/prompts/brain/route-flaky.yaml +9 -0
  9. package/evals/prompts/brain/route-heal.yaml +9 -0
  10. package/evals/prompts/brain/route-regression-pack.yaml +9 -0
  11. package/evals/prompts/brain/route-review-update.yaml +10 -0
  12. package/evals/prompts/brain/route-run-cases.yaml +9 -0
  13. package/evals/prompts/brain/route-story-cases.yaml +12 -0
  14. package/forgeqe.example.yaml +15 -0
  15. package/package.json +1 -1
  16. package/prompts/README.md +30 -0
  17. package/prompts/brain.md +34 -49
  18. package/prompts/browser-checks.md +2 -0
  19. package/py/forgeqe_engine/checks.py +8 -2
  20. package/py/forgeqe_engine/llm.py +33 -5
  21. package/py/forgeqe_engine/memory.py +59 -2
  22. package/py/forgeqe_engine/observe.py +123 -0
  23. package/py/forgeqe_engine/replay.py +55 -2
  24. package/py/forgeqe_engine/service.py +155 -3
  25. package/src/agents.ts +52 -0
  26. package/src/brain/index.ts +8 -2
  27. package/src/capabilities/registry.ts +48 -6
  28. package/src/cases/ado.ts +1 -1
  29. package/src/cases/journey.ts +61 -0
  30. package/src/cases/readiness.ts +35 -6
  31. package/src/cases/save.ts +241 -48
  32. package/src/cases/types.ts +11 -0
  33. package/src/cli/help.ts +9 -0
  34. package/src/cli/index.ts +71 -1
  35. package/src/code/coverage.ts +22 -3
  36. package/src/code/decisions.ts +73 -0
  37. package/src/code/find.ts +3 -1
  38. package/src/code/graph/graph.ts +33 -2
  39. package/src/code/loop.ts +17 -1
  40. package/src/code/notes.ts +48 -1
  41. package/src/code/prompt.ts +31 -9
  42. package/src/code/protect.ts +103 -0
  43. package/src/code/review.ts +2 -0
  44. package/src/code/runtime.ts +35 -3
  45. package/src/code/shortlist.ts +309 -0
  46. package/src/code/testrun.ts +3 -1
  47. package/src/code/traceability.ts +2 -0
  48. package/src/commands.ts +6 -1
  49. package/src/config.ts +46 -0
  50. package/src/context.ts +75 -0
  51. package/src/engine/bridge.ts +47 -5
  52. package/src/mcp/captools.ts +19 -0
  53. package/src/mcp/casetools.ts +107 -11
  54. package/src/mcp/commands.ts +74 -20
  55. package/src/mcp/packtools.ts +187 -0
  56. package/src/mcp/routing.ts +145 -0
  57. package/src/mcp/server.ts +7 -3
  58. package/src/mcp/tools.ts +241 -29
  59. package/src/pack/collect.ts +251 -0
  60. package/src/pack/index.ts +134 -0
  61. package/src/pack/init.ts +45 -0
  62. package/src/pack/pack.ts +327 -0
  63. package/src/pack/pipeline.ts +373 -0
  64. package/src/pack/run.ts +155 -0
  65. package/src/registry/db.ts +249 -0
  66. package/src/registry/index.ts +72 -0
  67. package/src/registry/resolve.ts +57 -0
  68. package/src/registry/tier0.ts +81 -0
  69. package/src/report/data.ts +7 -0
  70. package/src/report/html.ts +14 -0
  71. package/src/report/template.html +52 -48
  72. package/src/report/usage.ts +113 -0
  73. package/src/run.ts +22 -2
  74. package/src/suite/run.ts +12 -5
  75. package/src/summary.ts +1 -0
  76. package/src/tokenomics/budget.ts +172 -0
  77. package/src/tokenomics/ledger.ts +287 -0
  78. package/src/tui/App.tsx +89 -2
  79. package/src/tui/inspector.tsx +40 -35
  80. package/src/types.ts +9 -2
@@ -0,0 +1,309 @@
1
+ import path from "node:path"
2
+
3
+ import type { MemoryElement, MemoryPage } from "../memory.ts"
4
+ import { allMembers, elementRefs, membersLocating, qualified, testsReachingMembers, type CodeGraph, type Located } from "./graph/graph.ts"
5
+
6
+ /**
7
+ * Which of a repository's tests a request is about, before any browser run and without a model: hundreds of tests
8
+ * cannot all be shown to ForgeQE's agent, so they are ranked first and the closest few are shown with what each does.
9
+ *
10
+ * Three signals, each weighted by how rare it is among the tests (a link every test clicks says little):
11
+ * - elements: the request names elements page memory knows ("Select Skills", "Email"); the page-object members that
12
+ * locate them, and the tests that reach those members, are found through the code graph, as coverage does after a run.
13
+ * - pages: the request names a page ("the Practice tab", a URL path); tests that reach that page's elements.
14
+ * - names: the request shares words with a test's class, method, case ids, or categories; a case id is decisive.
15
+ */
16
+
17
+ /** A test of the repository, as the shortlist shows it. */
18
+ export type TestEntry = {
19
+ /** Class.Method, or the test's title for a top-level test. */
20
+ test: string
21
+ file: string
22
+ line: number
23
+ lines: number
24
+ /** Other tests in the same class: a test can be added next to them. */
25
+ siblings: number
26
+ /** Case ids and categories from its attributes or decorators. */
27
+ tags: string[]
28
+ /** What it calls, in order, without assertions and framework plumbing. */
29
+ steps: string[]
30
+ }
31
+
32
+ export type Shortlisted = TestEntry & {
33
+ score: number
34
+ /** Elements the request names that this test reaches, and the ones it does not. */
35
+ covers: string[]
36
+ lacks: string[]
37
+ why: string[]
38
+ }
39
+
40
+ export type Shortlist = { candidates: Shortlisted[]; elements: string[]; unlocated: string[]; pages: string[]; tests: number; ms: number }
41
+
42
+ /** A test this close is worth showing. */
43
+ export const SHORTLIST_FROM = 0.2
44
+ const MAX_ELEMENTS = 40
45
+ /** How much the element signal counts when the request names a single known element. */
46
+ const ONE_ELEMENT = 0.4
47
+ const MAX_PAGE_ELEMENTS = 150
48
+ const MAX_STEPS = 14
49
+
50
+ const STOP = new Set(
51
+ "a an and the of in on to with within only each every any all then this that is are be as it its if for from by or not when also into page step steps test tests flow verify verifying check checks expected appears appear present remains accepted go open navigate click fill type enter select see should can must sure make use using value values field fields realistic data async com org net www http https html".split(
52
+ " ",
53
+ ),
54
+ )
55
+
56
+ /** Words of an identifier or phrase, endings off: FillSkillsSearchAsync → fill, skill, search. */
57
+ function words(s: string): string[] {
58
+ return s
59
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
60
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
61
+ .toLowerCase()
62
+ .split(/[^a-z0-9]+/)
63
+ .filter((w) => w.length > 1 && !STOP.has(w))
64
+ .map((w) => w.replace(/(ing|ed|es|s)$/, ""))
65
+ .filter((w) => w.length > 1)
66
+ }
67
+
68
+ const norm = (s: string) => ` ${s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim()} `
69
+
70
+ /** Assertions, waits, and plumbing: not steps of the journey. */
71
+ const NOISE = /^(ConfigureAwait|Expect|expect|That|AreEqual|IsTrue|IsFalse|IsNotNull|Equal|True|False|assert\w*|Assert\w*|to[A-Z]\w*|To[A-Z]\w*|Not\w*|not|should\w*|Should\w*|log|Log\w*|WriteLine|WaitFor\w*|waitFor\w*|Delay|sleep|Sleep|StepAsync|Step|step|Run|GetAwaiter|GetResult|Result|toString|ToString|Format|Contains|Trim|Lower\w*|Upper\w*)$/
72
+ const NOISE_RECEIVER = /^(Assertions|Assert|ClassicAssert|expect|console|Console|Task|string|String|Math|this\.Page|Page|page|TestContext|Logger|logger|log)$/
73
+
74
+ /** Case ids (WEB-014, ADO-2238) and categories from a test's declaration. */
75
+ function tagsOf(signature: string): string[] {
76
+ const ids = [...signature.matchAll(/\b([A-Z][A-Z0-9]+-\d+)\b/g)].map((m) => m[1]!)
77
+ const cats = [...signature.matchAll(/(?:Category|Tag|Trait|mark\.)\s*\(?\s*"?([A-Za-z][\w-]*)"?/g)].map((m) => m[1]!).filter((c) => !/^(Test|TestCase)$/.test(c))
78
+ const at = [...signature.matchAll(/@([a-z][\w-]+)/g)].map((m) => `@${m[1]}`)
79
+ return [...new Set([...ids, ...cats, ...at])]
80
+ }
81
+
82
+ function entryOf(at: Located, siblings: number): TestEntry {
83
+ const steps: string[] = []
84
+ // In source order: the parser gives calls in the order it meets them in the tree, not as the test runs them.
85
+ const calls = at.member.calls.map((c, i) => ({ c, i })).sort((a, b) => a.c.line - b.c.line || a.i - b.i).map(({ c }) => c)
86
+ for (const c of calls) {
87
+ if (NOISE.test(c.name) || NOISE_RECEIVER.test(c.receiver)) continue
88
+ const step = c.receiver && !/^(this|self|base|super)$/.test(c.receiver) ? `${c.receiver}.${c.name}` : c.name
89
+ if (steps[steps.length - 1] !== step) steps.push(step)
90
+ }
91
+ return {
92
+ test: qualified(at),
93
+ file: at.file,
94
+ line: at.member.line,
95
+ lines: at.member.endLine - at.member.line + 1,
96
+ siblings,
97
+ tags: tagsOf(at.member.signature),
98
+ steps: steps.length > MAX_STEPS ? [...steps.slice(0, MAX_STEPS), `… ${steps.length - MAX_STEPS} more`] : steps,
99
+ }
100
+ }
101
+
102
+ /** Every test in the graph, with its entry. */
103
+ export function indexTests(g: CodeGraph): { at: Located; entry: TestEntry }[] {
104
+ const tests = allMembers(g).filter((l) => l.member.kind === "test")
105
+ const perClass = new Map<string, number>()
106
+ for (const t of tests) perClass.set(`${t.file}#${t.cls?.name || ""}`, (perClass.get(`${t.file}#${t.cls?.name || ""}`) || 0) + 1)
107
+ return tests.map((at) => ({ at, entry: entryOf(at, (perClass.get(`${at.file}#${at.cls?.name || ""}`) || 1) - 1) }))
108
+ }
109
+
110
+ /** The host a request names, if any. */
111
+ function hostOf(request: string, url?: string): string {
112
+ const m = /https?:\/\/(?:www\.)?([^/\s,)]+)/i.exec(url || request)
113
+ return (m?.[1] || "").toLowerCase().replace(/[.:;!?]+$/, "")
114
+ }
115
+
116
+ const hostMatches = (page: MemoryPage, host: string) => !host || page.host.replace(/^www\./, "") === host
117
+
118
+ /** Hosts page memory knows whose name the request says without a URL: "Open Wikipedia" is en.wikipedia.org. */
119
+ function hostsNamed(pages: MemoryPage[], request: string): Set<string> {
120
+ const text = norm(request)
121
+ const hosts = new Set<string>()
122
+ for (const p of pages) {
123
+ const labels = p.host.replace(/^www\./, "").split(".").slice(0, -1).filter((l) => l.length >= 4 && !/^(www|com|org|net|app|en|de|fr)$/.test(l))
124
+ if (labels.some((l) => text.includes(` ${l} `) || text.includes(` ${l.replace(/-/g, " ")} `))) hosts.add(p.host.replace(/^www\./, ""))
125
+ }
126
+ return hosts
127
+ }
128
+
129
+ /** Only locators that name one element: a page's other elements located by CSS or text match code of any page. */
130
+ const STABLE = new Set(["testid", "id", "role", "label", "placeholder", "name"])
131
+
132
+ /** The label a person would use for an element: its accessible name, text, or placeholder. */
133
+ function labelOf(e: MemoryElement): string {
134
+ const a = e.attributes || {}
135
+ const label = (e.name || a["aria-label"] || a.placeholder || e.text || "").replace(/\s+/g, " ").trim()
136
+ return label.length >= 3 && label.length <= 60 ? label : ""
137
+ }
138
+
139
+ /** The pages a request names: by its URL path, or a word of the page's path or title ("the Practice tab"). */
140
+ function pagesNamed(pages: MemoryPage[], request: string, url?: string): MemoryPage[] {
141
+ const text = norm(request)
142
+ // The site's own name is not a page's: "Wikipedia" names the site, not every page titled "… - Wikipedia".
143
+ const site = new Set(pages.flatMap((p) => p.host.split(".")))
144
+ const paths = [url, ...(request.match(/https?:\/\/\S+/g) || [])].filter(Boolean).map((u) => {
145
+ try {
146
+ return new URL(String(u).replace(/[.,;)]+$/, "")).pathname.replace(/\/+$/, "") || "/"
147
+ } catch {
148
+ return ""
149
+ }
150
+ })
151
+ return pages.filter((p) => {
152
+ if (p.path !== "/" && paths.includes(p.path.replace(/\/+$/, ""))) return true
153
+ const named = [...p.path.split(/[/._-]+/), ...(p.title || "").split(/[^A-Za-z0-9]+/)].map((w) => w.toLowerCase()).filter((w) => w.length >= 4 && !site.has(w) && !STOP.has(w) && !/^(html?|index|aspx|php|www|com|main|home)$/.test(w))
154
+ return named.some((w) => text.includes(` ${w} `))
155
+ })
156
+ }
157
+
158
+ /** Tests reached through the members that locate any of these elements. */
159
+ function testsThrough(g: CodeGraph, elements: MemoryElement[], stableOnly = false): Set<string> {
160
+ return reachOf(g, elements, stableOnly).tests
161
+ }
162
+
163
+ /** The page-object members that locate these elements, and the tests that reach them. */
164
+ function reachOf(g: CodeGraph, elements: MemoryElement[], stableOnly = false): { members: string[]; tests: Set<string> } {
165
+ const refsOf = (e: MemoryElement) => elementRefs(e).filter((r) => !stableOnly || (STABLE.has(r.kind) && Boolean(r.value)))
166
+ const members = elements.flatMap((e) => membersLocating(g, refsOf(e)).filter((m) => m.how === "exact" || (!stableOnly && m.how !== "param")).map((m) => m.at))
167
+ const seen = new Set<string>()
168
+ const unique = members.filter((m) => {
169
+ const k = `${m.file}|${qualified(m)}`
170
+ return seen.has(k) ? false : (seen.add(k), true)
171
+ })
172
+ return { members: unique.map(qualified), tests: new Set(testsReachingMembers(g, unique).map((t) => `${t.file}#${qualified(t)}`)) }
173
+ }
174
+
175
+ /** The repository's tests closest to a request, best first, with what each covers of the elements it names. */
176
+ export function shortlistTests(opts: { graph: CodeGraph; pages: MemoryPage[]; request: string; url?: string; max?: number }): Shortlist {
177
+ const started = performance.now()
178
+ const { graph, request } = opts
179
+ const index = indexTests(graph)
180
+ const n = index.length
181
+ const done = (candidates: Shortlisted[], extra: Partial<Shortlist> = {}): Shortlist => ({ candidates, elements: [], unlocated: [], pages: [], tests: n, ms: Math.round(performance.now() - started), ...extra })
182
+ if (!n) return done([])
183
+ const host = hostOf(request, opts.url)
184
+ const hosts = host ? null : hostsNamed(opts.pages, request)
185
+ const scope = opts.pages.filter((p) => (hosts?.size ? hosts.has(p.host.replace(/^www\./, "")) : hostMatches(p, host)))
186
+ const idf = (reach: number) => Math.log(1 + n / (1 + reach))
187
+
188
+ // Pages the request names, and the tests that reach their elements.
189
+ const pages = pagesNamed(scope, request, opts.url)
190
+ // Each element of those pages, weighted by how few tests reach it: a generic "Search" button other sites' page
191
+ // objects also locate says little; the elements only this page's tests use say the most.
192
+ const pageElements = pages.flatMap((p) => Object.values(p.elements || {}).filter((e) => elementRefs(e).some((r) => STABLE.has(r.kind))).slice(0, MAX_PAGE_ELEMENTS))
193
+ const pageWeight = new Map<string, number>()
194
+ for (const e of pageElements) {
195
+ const reach = testsThrough(graph, [e], true)
196
+ for (const t of reach) pageWeight.set(t, (pageWeight.get(t) || 0) + idf(reach.size))
197
+ }
198
+ const pageTop = Math.max(0, ...pageWeight.values())
199
+
200
+ // Elements the request names, longest labels first ("Select Skills" before "Skills"). On the pages it names when it
201
+ // names any ("Email" is on many pages), else anywhere on the site; every element with that label counts.
202
+ const text = norm(request)
203
+ const collect = (from: MemoryPage[]) => {
204
+ const named = new Map<string, MemoryElement[]>()
205
+ for (const page of from)
206
+ for (const e of Object.values(page.elements || {})) {
207
+ const key = norm(labelOf(e))
208
+ if (key.trim() && key.trim().split(" ").some((w) => !STOP.has(w)) && text.includes(key)) named.set(key, [...(named.get(key) || []), e])
209
+ }
210
+ return named
211
+ }
212
+ const onPages = pages.length ? collect(pages) : new Map<string, MemoryElement[]>()
213
+ const named = onPages.size ? onPages : collect(scope)
214
+ const labels = [...named.keys()].sort((a, b) => b.length - a.length).slice(0, MAX_ELEMENTS)
215
+ // A shorter label inside a longer one the request names ("skills" in "select skills") is the same mention.
216
+ const kept = labels.filter((l, i) => !labels.slice(0, i).some((longer) => longer.includes(l)))
217
+ const elementReach = kept.map((key) => {
218
+ const r = reachOf(graph, named.get(key)!)
219
+ return { label: labelOf(named.get(key)![0]!), reach: r.tests, members: r.members }
220
+ })
221
+ // Located: a page-object member finds it, whether or not a test uses it yet (then every test lacks it).
222
+ const located = elementReach.filter((e) => e.members.length)
223
+ const unlocated = elementReach.filter((e) => !e.members.length).map((e) => e.label)
224
+ const elementWeight = located.map((e) => idf(e.reach.size))
225
+ const elementTotal = elementWeight.reduce((a, b) => a + b, 0)
226
+
227
+ // Words: a request word in few test names says more than one in many.
228
+ const reqWords = new Set(words(request))
229
+ const nameWords = index.map(({ at, entry }) => new Set([...words(at.cls?.name || ""), ...words(at.member.name), ...entry.tags.flatMap(words)]))
230
+ const df = new Map<string, number>()
231
+ for (const ws of nameWords) for (const w of ws) df.set(w, (df.get(w) || 0) + 1)
232
+ const reqWeight = [...reqWords].reduce((sum, w) => sum + idf(df.get(w) || 0) ** 2, 0)
233
+ const reqIds = new Set((request.match(/\b[A-Z][A-Z0-9]+-\d+\b/g) || []).map((s) => s.toUpperCase()))
234
+
235
+ const scored: Shortlisted[] = index.map(({ at, entry }, i) => {
236
+ const key = `${at.file}#${qualified(at)}`
237
+ const why: string[] = []
238
+ const covers = located.filter((e) => e.reach.has(key)).map((e) => e.label)
239
+ const lacks = located.filter((e) => !e.reach.has(key)).map((e) => e.label)
240
+ const elem = elementTotal ? located.reduce((s, e, j) => s + (e.reach.has(key) ? elementWeight[j]! : 0), 0) / elementTotal : 0
241
+ if (covers.length) why.push(`reaches ${covers.length} of the ${located.length} elements the request names`)
242
+ const page = pageTop ? (pageWeight.get(key) || 0) / pageTop : 0
243
+ if (page >= 0.5) why.push(`works on ${pages.map((p) => p.path).join(", ")}`)
244
+ const ws = nameWords[i]!
245
+ // Cosine of the two word sets, each word weighted by how rare it is among test names.
246
+ let shared = 0
247
+ let own = 0
248
+ for (const w of ws) {
249
+ const weight = idf(df.get(w) || 0) ** 2
250
+ own += weight
251
+ if (reqWords.has(w)) shared += weight
252
+ }
253
+ // How much of the test's name the request says, with the cosine so a long name is not lost against a short one.
254
+ const name = own && reqWeight ? 0.6 * (shared / own) + 0.4 * (shared / Math.sqrt(own * reqWeight)) : 0
255
+ if (name >= 0.3) why.push(`its name shares "${[...ws].filter((w) => reqWords.has(w)).join(", ")}" with the request`)
256
+ const caseHit = entry.tags.some((t) => reqIds.has(t.toUpperCase()))
257
+ if (caseHit) why.push(`tagged ${entry.tags.filter((t) => reqIds.has(t.toUpperCase())).join(", ")}, which the request names`)
258
+ // Without elements named, pages and names carry the score.
259
+ // One element the request names may be a generic word ("use the language search"); two or more are its journey.
260
+ const strength = located.length >= 2 ? 1 : located.length === 1 ? ONE_ELEMENT : 0
261
+ const score = strength * (0.45 * elem + 0.2 * page + 0.35 * name) + (1 - strength) * (0.35 * page + 0.65 * name) + (caseHit ? 1 : 0)
262
+ return { ...entry, score: Number(score.toFixed(3)), covers, lacks, why }
263
+ })
264
+ const candidates = scored
265
+ .filter((c) => c.score >= SHORTLIST_FROM)
266
+ .sort((a, b) => b.score - a.score || b.covers.length - a.covers.length || a.lines - b.lines)
267
+ .slice(0, opts.max ?? 6)
268
+ return done(candidates, { elements: located.map((e) => e.label), unlocated, pages: pages.map((p) => `${p.host}${p.path}`) })
269
+ }
270
+
271
+ /**
272
+ * The command that runs one test, built the way the repository already runs one: from a command an earlier job used
273
+ * (its filter swapped for this test), else the framework's usual form. Null when neither fits.
274
+ */
275
+ export function oneTestCommand(repo: string, t: Pick<TestEntry, "test" | "file">, known: string[], files: string[]): string | null {
276
+ const [cls, method] = t.test.includes(".") ? [t.test.slice(0, t.test.lastIndexOf(".")), t.test.slice(t.test.lastIndexOf(".") + 1)] : ["", t.test]
277
+ const ext = path.extname(t.file)
278
+ if (ext === ".cs") {
279
+ const filter = known.find((c) => /--filter\s+"?FullyQualifiedName~/.test(c))
280
+ if (filter) return filter.replace(/(FullyQualifiedName~)[^"\s]+/, `$1${cls}.${method}`)
281
+ // The test project nearest the file.
282
+ const projects = files.filter((f) => f.endsWith(".csproj")).filter((p) => t.file.startsWith(path.dirname(p) + "/")).sort((a, b) => b.length - a.length)
283
+ return projects[0] ? `dotnet test ${projects[0]} --filter "FullyQualifiedName~${cls}.${method}"` : null
284
+ }
285
+ if ([".ts", ".js", ".mjs", ".tsx"].includes(ext) && files.some((f) => /(^|\/)playwright\.config\.(ts|js|mjs|cjs)$/.test(f))) return `npx playwright test ${t.file} -g "${method.replace(/"/g, '\\"')}"`
286
+ if (ext === ".py") return `pytest ${t.file}::${cls ? `${cls}::` : ""}${method}`
287
+ if (ext === ".java") {
288
+ if (files.some((f) => /(^|\/)pom\.xml$/.test(f))) return `mvn test -Dtest="${cls}#${method}"`
289
+ if (files.some((f) => /(^|\/)build\.gradle(\.kts)?$/.test(f))) return `./gradlew test --tests "*${cls}.${method}"`
290
+ }
291
+ void repo
292
+ return null
293
+ }
294
+
295
+ /**
296
+ * The tests that work on the pages of these areas (/checkout, /api/cart/:id): they reach elements page memory knows on
297
+ * those pages, through stable locators. Keyed file#Class.Method, with the areas each one reaches.
298
+ */
299
+ export function testsOnAreas(graph: CodeGraph, pages: MemoryPage[], areas: string[]): Map<string, string[]> {
300
+ const out = new Map<string, string[]>()
301
+ for (const area of areas) {
302
+ const pattern = new RegExp(`^${area.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\:[^/]+|:[^/]+/g, "[^/]+")}/?$`, "i")
303
+ const on = pages.filter((p) => pattern.test(p.path))
304
+ if (!on.length) continue
305
+ const reach = testsThrough(graph, on.flatMap((p) => Object.values(p.elements || {}).filter((e) => elementRefs(e).some((r) => STABLE.has(r.kind))).slice(0, MAX_PAGE_ELEMENTS)), true)
306
+ for (const t of reach) out.set(t, [...(out.get(t) || []), area])
307
+ }
308
+ return out
309
+ }
@@ -142,6 +142,8 @@ export async function runTestCommand(opts: {
142
142
  save?: string
143
143
  /** Variables the test must not see: ForgeQE's own secrets (privateEnvNames). */
144
144
  hide?: string[]
145
+ /** Variables the test gets on top of ForgeQE's own (a remote browser's endpoint): never put in the command line. */
146
+ env?: Record<string, string>
145
147
  }): Promise<TestRun> {
146
148
  const started = Date.now()
147
149
  const windows = process.platform === "win32"
@@ -149,7 +151,7 @@ export async function runTestCommand(opts: {
149
151
  const proc = Bun.spawn(argv, {
150
152
  cwd: opts.cwd,
151
153
  windowsVerbatimArguments: windows,
152
- env: { ...withoutNames(process.env, opts.hide || []), ...sessionLoginEnv(), CI: process.env.CI || "1", PLAYWRIGHT_HTML_OPEN: "never", FORCE_COLOR: "0", NO_COLOR: "1" },
154
+ env: { ...withoutNames(process.env, opts.hide || []), ...sessionLoginEnv(), CI: process.env.CI || "1", PLAYWRIGHT_HTML_OPEN: "never", FORCE_COLOR: "0", NO_COLOR: "1", ...(opts.env || {}) },
153
155
  stdin: "ignore",
154
156
  stdout: "pipe",
155
157
  stderr: "pipe",
@@ -33,6 +33,8 @@ export type Mapping = {
33
33
  bug?: { label: string; url?: string }
34
34
  /** The needs-attention folder, for an unfinished test. */
35
35
  attention?: string
36
+ /** The case's steps when the test was written (stepsHash): when they change, the next run updates the test. */
37
+ stepsHash?: string
36
38
  updatedAt: string
37
39
  }
38
40
 
package/src/commands.ts CHANGED
@@ -14,6 +14,8 @@ export type ShellCommand =
14
14
  | { type: "init" }
15
15
  /** Sign in to a system (or save a secret by name), forget one, or show which are signed in. */
16
16
  | { type: "auth"; action: "status" | "login" | "logout"; system?: string }
17
+ /** Run a passed run again as recorded, without ForgeQE's agent: the last one, a run folder, or a goal. */
18
+ | { type: "rerun"; target: string }
17
19
  | { type: "text"; text: string }
18
20
  | { type: "unknown"; raw: string }
19
21
 
@@ -30,6 +32,7 @@ export const HELP = [
30
32
  " Add a GitHub Actions workflow that runs tests/cases.yaml on every PR, and make it green",
31
33
  "",
32
34
  " /stop stop what is running (or press esc)",
35
+ " /rerun [last|run] run a passed run again as recorded, without ForgeQE's agent (last, a run folder, or its goal)",
33
36
  " /headed on|off show the browser (live profiles)",
34
37
  " /details [on|off] show everything the agents do (reasoning, every file read, engine logs); off: just progress",
35
38
  " /profile <name> switch LLM profile (or press tab)",
@@ -46,7 +49,7 @@ export const HELP = [
46
49
  "Select text with the mouse to copy it.",
47
50
  ].join("\n")
48
51
 
49
- const COMMANDS = new Set(["help", "?", "exit", "quit", "q", "clear", "stop", "headed", "details", "verbose", "profile", "p", "logs", "skills", "features", "auth", "signin", "init"])
52
+ const COMMANDS = new Set(["help", "?", "exit", "quit", "q", "clear", "stop", "headed", "details", "verbose", "profile", "p", "logs", "skills", "features", "auth", "signin", "init", "rerun"])
50
53
 
51
54
  /** Session controls; every other line, including one that starts with a path, goes to the agent as text. */
52
55
  export function parseCommand(raw: string): ShellCommand {
@@ -80,6 +83,8 @@ export function parseCommand(raw: string): ShellCommand {
80
83
  return { type: "features" }
81
84
  case "init":
82
85
  return { type: "init" }
86
+ case "rerun":
87
+ return { type: "rerun", target: arg || "last" }
83
88
  case "auth":
84
89
  case "signin": {
85
90
  const [first, ...rest] = arg.split(/\s+/).filter(Boolean)
package/src/config.ts CHANGED
@@ -15,6 +15,7 @@ import { systemLocale } from "./locale.ts"
15
15
  import { currencyCode, currencyForLocale } from "./money.ts"
16
16
  import { listPrice, pricesDate, type DeploymentType, type ListPrice } from "./prices.ts"
17
17
  import { ragConfig, type RagConfig } from "./cases/rag.ts"
18
+ import { budgetConfig, type BudgetConfig } from "./tokenomics/budget.ts"
18
19
 
19
20
  export type BrowserProvider = "local" | "browserstack" | "testmu"
20
21
  export type RemoteBrowserProvider = Exclude<BrowserProvider, "local">
@@ -72,6 +73,9 @@ export type MemoryConfig = {
72
73
  replay: ReplayMode
73
74
  }
74
75
 
76
+ /** registry: in forgeqe.yaml (docs/COST_ARCHITECTURE.md). on by default; off: every request takes the agent's path. */
77
+ export type RegistryConfig = { mode: "off" | "shadow" | "on" }
78
+
75
79
  function runtimeBin(value: unknown, base: string): string | undefined {
76
80
  if (!value) return undefined
77
81
  const s = String(value)
@@ -373,6 +377,8 @@ export type AppConfig = {
373
377
  defects: DefectsConfig
374
378
  export: { outDir: string; format: string }
375
379
  memory: MemoryConfig
380
+ /** Unset: on. */
381
+ registry?: RegistryConfig
376
382
  code: CodeConfig
377
383
  sources: SourcesConfig
378
384
  suite: SuiteConfig
@@ -388,6 +394,8 @@ export type AppConfig = {
388
394
  rag?: RagConfig
389
395
  /** Flaky tests: retries in one run, and what to do with tests that keep flip-flopping (see src/code/history.ts). */
390
396
  flaky?: FlakyConfig
397
+ /** Regression packs: the pipelines that run one (packs.pipeline in forgeqe.yaml). */
398
+ packs?: PacksConfig
391
399
  /** Thresholds of the release-readiness verdict (release: in forgeqe.yaml). */
392
400
  release?: ReleaseThresholds
393
401
  /** API and contract tests (api: in forgeqe.yaml): see src/api/. */
@@ -400,6 +408,8 @@ export type AppConfig = {
400
408
  domain?: DomainConfig
401
409
  /** Teams and email notifications for suites, release readiness, and evaluations (notify: in forgeqe.yaml): see src/notify/. */
402
410
  notify?: NotifyConfig
411
+ /** Model spending budgets per run, day and month (budget: in forgeqe.yaml); they only warn. See src/tokenomics/budget.ts. */
412
+ budget?: BudgetConfig
403
413
  }
404
414
 
405
415
  export type DomainConfig = {
@@ -427,6 +437,13 @@ export function replayMode(raw: unknown): ReplayMode {
427
437
  return "verify"
428
438
  }
429
439
 
440
+ export function registryModeOf(raw: unknown): RegistryConfig["mode"] {
441
+ const mode = String(raw ?? "on").toLowerCase()
442
+ if (mode === "off" || mode === "shadow" || mode === "on") return mode
443
+ if (raw === false) return "off"
444
+ throw new Error(`Unknown registry mode "${String(raw)}". Use off, shadow or on.`)
445
+ }
446
+
430
447
  const MOCK_PROFILE: LlmProfile = { provider: "mock", model: "mock", temperature: 0 }
431
448
  const NO_PROFILE: LlmProfile = { provider: "none", model: "" }
432
449
 
@@ -868,6 +885,7 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
868
885
  : path.join(forgeqeHome(), "memory"),
869
886
  replay: replayMode(memory.replay),
870
887
  },
888
+ registry: { mode: registryModeOf(asRecord(yaml.registry).mode) },
871
889
  code: {
872
890
  repo: code.repo ? path.resolve(base, String(code.repo)) : undefined,
873
891
  maxAttempts: Number(code.max_attempts || 3),
@@ -888,7 +906,9 @@ export function loadConfig(opts?: { configPath?: string; profile?: string }): Ap
888
906
  sources: sourcesConfig(sources),
889
907
  ...(ragConfig(yaml.rag) ? { rag: ragConfig(yaml.rag) } : {}),
890
908
  flaky: flakyConfig(asRecord(yaml.flaky)),
909
+ packs: packsConfig(asRecord(yaml.packs)),
891
910
  release: releaseThresholds(yaml.release),
911
+ ...(budgetConfig(yaml.budget) ? { budget: budgetConfig(yaml.budget) } : {}),
892
912
  api: apiConfig(asRecord(yaml.api)),
893
913
  bi: biConfig(asRecord(yaml.bi), base, outDir, configPath),
894
914
  quality: withBaselineDir(qualityConfig(yaml.quality), base, configPath),
@@ -1100,6 +1120,32 @@ function githubConfig(raw: unknown): SourcesConfig["github"] {
1100
1120
  }
1101
1121
  }
1102
1122
 
1123
+ /** Where `forgeqe pack run --on pipeline` runs a pack: a GitHub Actions workflow, an Azure DevOps pipeline, or both. */
1124
+ export type PacksConfig = {
1125
+ pipeline: {
1126
+ default?: "github" | "azure_devops"
1127
+ github?: { repo: string; workflow: string; ref: string }
1128
+ azureDevops?: { project: string; pipelineId: number; ref: string }
1129
+ /** Longest wait for a pipeline run, in minutes. */
1130
+ timeoutMinutes: number
1131
+ }
1132
+ }
1133
+
1134
+ function packsConfig(p: Record<string, unknown>): PacksConfig {
1135
+ const pipe = asRecord(p.pipeline)
1136
+ const gh = asRecord(pipe.github)
1137
+ const ado = asRecord(pipe.azure_devops)
1138
+ const def = String(pipe.default || "")
1139
+ return {
1140
+ pipeline: {
1141
+ ...(def === "github" || def === "azure_devops" ? { default: def } : {}),
1142
+ ...(gh.repo ? { github: { repo: String(gh.repo), workflow: String(gh.workflow || "forgeqe-pack.yml"), ref: String(gh.ref || "main") } } : {}),
1143
+ ...(ado.project && Number(ado.pipeline_id) > 0 ? { azureDevops: { project: String(ado.project), pipelineId: Number(ado.pipeline_id), ref: String(ado.ref || "refs/heads/main") } } : {}),
1144
+ timeoutMinutes: Number(pipe.timeout_minutes) > 0 ? Number(pipe.timeout_minutes) : 60,
1145
+ },
1146
+ }
1147
+ }
1148
+
1103
1149
  function flakyConfig(f: Record<string, unknown>): FlakyConfig {
1104
1150
  const q = String(f.quarantine ?? "suggest")
1105
1151
  return {
package/src/context.ts ADDED
@@ -0,0 +1,75 @@
1
+ import { AGENTS, type AgentEntry } from "./agents.ts"
2
+ import { createRouter, tokens } from "./mcp/routing.ts"
3
+ import { TOOLS } from "./mcp/tools.ts"
4
+ import { prompt } from "./prompts.ts"
5
+
6
+ /** One agent's fixed context: what it carries on every model call. */
7
+ export type AgentContext = {
8
+ agent: AgentEntry
9
+ /** The system prompt as sent (ForgeQE's agent: the core, without its on-demand playbooks). */
10
+ prompt: number
11
+ /** ForgeQE's agent only: its tool list as sent. */
12
+ tools?: number
13
+ total: number
14
+ over: boolean
15
+ /** ForgeQE's agent only: what it gets on demand, and what the fixed context would be with everything always on. */
16
+ onDemand?: { playbooks: number; playbookTokens: number; everything: number }
17
+ }
18
+
19
+ const expanded = (name: string) => {
20
+ try {
21
+ return prompt(name)
22
+ } catch {
23
+ return ""
24
+ }
25
+ }
26
+
27
+ /** The fixed context of each agent, from the prompts and tools as they are now (a team's own prompt copies included). */
28
+ export function agentContexts(): AgentContext[] {
29
+ return AGENTS.map((agent) => {
30
+ if (agent.name !== "forgeqe") {
31
+ const p = agent.prompts.reduce((sum, n) => sum + tokens(expanded(n)), 0)
32
+ return { agent, prompt: p, total: p, over: p > agent.budget }
33
+ }
34
+ const router = createRouter(TOOLS)
35
+ const p = tokens(router.parts.core)
36
+ const t = tokens(JSON.stringify(TOOLS.map((x) => ({ name: x.name, description: x.description, inputSchema: x.inputSchema }))))
37
+ const full = tokens(expanded("brain")) + t
38
+ return {
39
+ agent,
40
+ prompt: p,
41
+ tools: t,
42
+ total: p + t,
43
+ over: p + t > agent.budget,
44
+ onDemand: {
45
+ playbooks: router.parts.playbooks.length,
46
+ playbookTokens: router.parts.playbooks.reduce((s, b) => s + tokens(b.text), 0),
47
+ everything: full,
48
+ },
49
+ }
50
+ })
51
+ }
52
+
53
+ const k = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n))
54
+
55
+ /** `forgeqe context`: each agent's fixed context against its budget, and what ForgeQE's agent loads on demand. */
56
+ export function contextReport(rows = agentContexts()): string[] {
57
+ const out = ["Fixed context of each agent: sent again on every model call (~tokens).", ""]
58
+ const w = Math.max(...rows.map((r) => r.agent.name.length))
59
+ for (const r of rows) {
60
+ const parts = r.tools !== undefined ? `prompt ${k(r.prompt)} + tools ${k(r.tools)}` : `prompt ${k(r.prompt)}`
61
+ out.push(`${r.over ? "✗" : "✓"} ${r.agent.name.padEnd(w)} ${k(r.total).padStart(6)} of ${k(r.agent.budget).padStart(6)} ${parts}`)
62
+ }
63
+ const f = rows.find((r) => r.onDemand)
64
+ if (f?.onDemand) {
65
+ const d = f.onDemand
66
+ out.push(
67
+ "",
68
+ `ForgeQE's agent loads on demand: ${d.playbooks} playbooks (${k(d.playbookTokens)}), each with the first result of its tools or through the playbook tool.`,
69
+ `With everything always loaded it would carry ${k(d.everything)} on every call; it carries ${k(f.total)}.`,
70
+ )
71
+ }
72
+ const over = rows.filter((r) => r.over)
73
+ if (over.length) out.push("", `Over budget: ${over.map((r) => r.agent.name).join(", ")}. Shorten what they load, or raise the budget in src/agents.ts with the reason.`)
74
+ return out
75
+ }