@capagents/forgeqe 1.2.1 → 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 (65) hide show
  1. package/HOW_TO_USE.md +39 -2
  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 +53 -6
  6. package/forgeqe.example.yaml +15 -0
  7. package/package.json +1 -1
  8. package/prompts/brain.md +35 -4
  9. package/prompts/browser-checks.md +2 -0
  10. package/py/forgeqe_engine/checks.py +8 -2
  11. package/py/forgeqe_engine/llm.py +33 -5
  12. package/py/forgeqe_engine/memory.py +59 -2
  13. package/py/forgeqe_engine/observe.py +123 -0
  14. package/py/forgeqe_engine/replay.py +55 -2
  15. package/py/forgeqe_engine/service.py +155 -3
  16. package/src/capabilities/registry.ts +19 -5
  17. package/src/cases/ado.ts +1 -1
  18. package/src/cases/journey.ts +61 -0
  19. package/src/cases/readiness.ts +35 -6
  20. package/src/cases/save.ts +241 -48
  21. package/src/cases/types.ts +11 -0
  22. package/src/cli/help.ts +8 -0
  23. package/src/cli/index.ts +64 -1
  24. package/src/code/coverage.ts +22 -3
  25. package/src/code/decisions.ts +73 -0
  26. package/src/code/find.ts +3 -1
  27. package/src/code/graph/graph.ts +33 -2
  28. package/src/code/loop.ts +17 -1
  29. package/src/code/notes.ts +48 -1
  30. package/src/code/prompt.ts +31 -9
  31. package/src/code/protect.ts +103 -0
  32. package/src/code/review.ts +2 -0
  33. package/src/code/runtime.ts +35 -3
  34. package/src/code/shortlist.ts +309 -0
  35. package/src/code/testrun.ts +3 -1
  36. package/src/code/traceability.ts +2 -0
  37. package/src/commands.ts +6 -1
  38. package/src/config.ts +46 -0
  39. package/src/engine/bridge.ts +47 -5
  40. package/src/mcp/casetools.ts +107 -11
  41. package/src/mcp/commands.ts +74 -20
  42. package/src/mcp/packtools.ts +187 -0
  43. package/src/mcp/tools.ts +238 -29
  44. package/src/pack/collect.ts +251 -0
  45. package/src/pack/index.ts +134 -0
  46. package/src/pack/init.ts +45 -0
  47. package/src/pack/pack.ts +327 -0
  48. package/src/pack/pipeline.ts +373 -0
  49. package/src/pack/run.ts +155 -0
  50. package/src/registry/db.ts +249 -0
  51. package/src/registry/index.ts +72 -0
  52. package/src/registry/resolve.ts +57 -0
  53. package/src/registry/tier0.ts +81 -0
  54. package/src/report/data.ts +7 -0
  55. package/src/report/html.ts +14 -0
  56. package/src/report/template.html +52 -48
  57. package/src/report/usage.ts +113 -0
  58. package/src/run.ts +22 -2
  59. package/src/suite/run.ts +12 -5
  60. package/src/summary.ts +1 -0
  61. package/src/tokenomics/budget.ts +172 -0
  62. package/src/tokenomics/ledger.ts +287 -0
  63. package/src/tui/App.tsx +89 -2
  64. package/src/tui/inspector.tsx +40 -35
  65. package/src/types.ts +9 -2
package/src/code/find.ts CHANGED
@@ -382,6 +382,8 @@ export async function runSelection(opts: {
382
382
  hide?: string[]
383
383
  /** Retries and quarantine; default: one retry, quarantine suggested. */
384
384
  flaky?: FlakyConfig
385
+ /** Extra variables for the tests, such as a remote browser's endpoint. */
386
+ env?: Record<string, string>
385
387
  }): Promise<TestsRun> {
386
388
  const sel = opts.selection
387
389
  const flakyCfg = opts.flaky || { retries: 1, quarantine: "suggest", flakyAfter: 2 }
@@ -389,7 +391,7 @@ export async function runSelection(opts: {
389
391
  mkdirSync(sel.dir, { recursive: true })
390
392
  for (const p of sel.results) if (p.startsWith(sel.dir) && existsSync(p) && statSync(p).isFile()) rmSync(p)
391
393
  const started = Date.now()
392
- const exec = (command: string) => (opts.runTest || runTestCommand)({ command, cwd: sel.repo, timeoutSeconds: opts.timeoutSeconds, signal: opts.signal, hide: opts.hide })
394
+ const exec = (command: string) => (opts.runTest || runTestCommand)({ command, cwd: sel.repo, timeoutSeconds: opts.timeoutSeconds, signal: opts.signal, hide: opts.hide, ...(opts.env ? { env: opts.env } : {}) })
393
395
  const read = (since: number) => {
394
396
  const out: TestOutcome[] = []
395
397
  for (const f of resultFiles(sel.results, since)) {
@@ -86,8 +86,15 @@ export async function buildGraph(repo: string, opts: { cacheDir?: string } = {})
86
86
  /** A member (or top-level function or test) with where it is. */
87
87
  export type Located = { file: string; cls?: ClassFacts; member: Member }
88
88
 
89
+ const membersOf = new WeakMap<CodeGraph, Located[]>()
90
+
91
+ /** Every member of the graph; worked out once per graph, which does not change once built. */
89
92
  export function allMembers(g: CodeGraph): Located[] {
90
- return g.files.flatMap((f) => [...f.classes.flatMap((cls) => cls.members.map((member) => ({ file: f.file, cls, member }))), ...f.functions.map((member) => ({ file: f.file, member }))])
93
+ const known = membersOf.get(g)
94
+ if (known) return known
95
+ const all = g.files.flatMap((f) => [...f.classes.flatMap((cls) => cls.members.map((member) => ({ file: f.file, cls, member }))), ...f.functions.map((member) => ({ file: f.file, member }))])
96
+ membersOf.set(g, all)
97
+ return all
91
98
  }
92
99
 
93
100
  export const qualified = (l: Located) => (l.cls ? `${l.cls.name}.${l.member.name}` : l.member.name)
@@ -314,8 +321,21 @@ export function describeMatches(g: CodeGraph, matches: ElementMatch[], max = 3):
314
321
  // References
315
322
 
316
323
  /** The class a receiver names: from the file's declarations, then any file's, then by name (articlePage → WikipediaArticlePage). */
324
+ const receivers = new WeakMap<CodeGraph, Map<string, string | null>>()
325
+
317
326
  function receiverClass(g: CodeGraph, file: FileFacts, receiver: string, classes: Set<string>): string | null {
318
327
  if (!receiver) return null
328
+ // The graph does not change once built: each file's receiver resolves the same way every time it is asked.
329
+ let cache = receivers.get(g)
330
+ if (!cache) receivers.set(g, (cache = new Map()))
331
+ const key = `${file.file}\u0000${receiver}\u0000${classes.size}`
332
+ if (cache.has(key)) return cache.get(key)!
333
+ const found = resolveReceiver(g, file, receiver, classes)
334
+ cache.set(key, found)
335
+ return found
336
+ }
337
+
338
+ function resolveReceiver(g: CodeGraph, file: FileFacts, receiver: string, classes: Set<string>): string | null {
319
339
  const last = receiver.split(".").pop() || receiver
320
340
  if (classes.has(last)) return last
321
341
  const bound = file.bindings[receiver] || file.bindings[last]
@@ -331,13 +351,24 @@ function receiverClass(g: CodeGraph, file: FileFacts, receiver: string, classes:
331
351
  }
332
352
 
333
353
  /** The class and its base classes, nearest first. */
354
+ const lineages = new WeakMap<CodeGraph, { byName: Map<string, ClassFacts>; memo: Map<string, string[]> }>()
355
+
334
356
  function lineage(g: CodeGraph, name: string): string[] {
357
+ let c = lineages.get(g)
358
+ if (!c) {
359
+ const byName = new Map<string, ClassFacts>()
360
+ for (const f of g.files) for (const cls of f.classes) if (!byName.has(cls.name)) byName.set(cls.name, cls)
361
+ lineages.set(g, (c = { byName, memo: new Map() }))
362
+ }
363
+ const known = c.memo.get(name)
364
+ if (known) return known
335
365
  const out: string[] = []
336
366
  let cur: string | undefined = name
337
367
  while (cur && !out.includes(cur)) {
338
368
  out.push(cur)
339
- cur = g.files.flatMap((f) => f.classes).find((c) => c.name === cur)?.bases[0]
369
+ cur = c.byName.get(cur)?.bases[0]
340
370
  }
371
+ c.memo.set(name, out)
341
372
  return out
342
373
  }
343
374
 
package/src/code/loop.ts CHANGED
@@ -10,6 +10,7 @@ import type { TestCase } from "../cases/types.ts"
10
10
  import { buildProfile, profileSection } from "./profile.ts"
11
11
  import { LIVE_BROWSER_SERVER, liveBrowser } from "./livebrowser.ts"
12
12
  import { additionFacts, buildGraph, classesUsedBy, outlines, type CodeGraph } from "./graph/graph.ts"
13
+ import { breachPrompt, checkOriginal, readOriginal } from "./protect.ts"
13
14
  import { CODE_GRAPH_SERVER, codeGraphTools } from "./graph/tools.ts"
14
15
  import { localToolServer } from "../mcp/server.ts"
15
16
  import type { ToolContext } from "../mcp/tools.ts"
@@ -19,6 +20,7 @@ import { openSession, READ_AGENT, TEST_AGENT, type OpencodeSession, type PromptO
19
20
  import { failureContext } from "./slices.ts"
20
21
  import { moveToAttention } from "./attention.ts"
21
22
  import { recordMapping } from "./traceability.ts"
23
+ import { stepsHash } from "../cases/types.ts"
22
24
  import {
23
25
  blockedChallengePrompt,
24
26
  carryOnPrompt,
@@ -167,6 +169,8 @@ export async function runCodeAgent(opts: {
167
169
  // locates it, Scribe gets code_* tools instead of reading whole files, and the reviewer gets what the job duplicated.
168
170
  const built = config.code.graph === false ? null : await buildGraph(repo).catch(() => null)
169
171
  const graph: CodeGraph | null = built?.files.length ? built : null
172
+ // The existing test the job works from, as it is now: what Scribe may do to it is checked before each test run.
173
+ const original = opts.target ? await readOriginal(repo, opts.target) : null
170
174
  let handoff = buildHandoff(opts.runDir, config.memory.dir, { knownDefect: opts.knownDefect, graph })
171
175
  if (!handoff.success && !opts.knownDefect) throw new Error("The coding agent only takes runs that passed. This run failed.")
172
176
  const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`
@@ -496,6 +500,17 @@ export async function runCodeAgent(opts: {
496
500
  result.reused = report.reused || []
497
501
  result.changed = report.changed || []
498
502
 
503
+ if (original && opts.target) {
504
+ const breach = await checkOriginal(repo, original, opts.target.mode)
505
+ if (breach) {
506
+ result.breaches = (result.breaches || 0) + 1
507
+ emit({ type: "code", kind: "test", ok: false, attempt, text: `${breach.why}: not run; Scribe puts it back (attempt ${attempt}/${allowed})` })
508
+ attemptLog.push(attemptLine(attempt, "changed the existing test it was asked to keep", breach.lines.slice(0, 3).join(" | "), report.notes))
509
+ nextTask = breachPrompt(original, breach, opts.target.mode)
510
+ next = { why: breach.why, fixing: [opts.target.mode === "extend" ? "Put back the existing test's lines exactly; add only the new steps" : "Leave the existing test as it was"] }
511
+ continue
512
+ }
513
+ }
499
514
  emit({ type: "code", kind: "info", text: `ForgeQE runs the test: ${report.command}` })
500
515
  const run = await runTest({
501
516
  command: report.command,
@@ -571,7 +586,7 @@ export async function runCodeAgent(opts: {
571
586
  result.status = "passed"
572
587
  // What it did with the existing test it was given, for the result card and the agent.
573
588
  if (opts.existingTest) result.existing = "updated"
574
- else if (opts.target) result.existing = opts.target.mode === "reuse" ? "reused" : opts.target.mode === "update" ? "updated" : "extended"
589
+ else if (opts.target) result.existing = opts.target.mode === "reuse" ? "reused" : opts.target.mode === "update" ? "updated" : opts.target.mode === "sibling" ? "added_beside" : "extended"
575
590
  if (standards.length) {
576
591
  result.review = { ...verdict.review, approved: true, standardsLeft: true }
577
592
  result.notes = `The test passes and tests the goal. Coding standards still to fix: ${standards.map((i) => `${i.file ? `${i.file}: ` : ""}${i.problem}`).join(" ")}`
@@ -652,6 +667,7 @@ export async function runCodeAgent(opts: {
652
667
  ...(result.testName ? { testName: result.testName } : {}),
653
668
  ...(result.command ? { command: result.command } : {}),
654
669
  ...(opts.knownDefect?.bug ? { bug: opts.knownDefect.bug } : {}),
670
+ ...(tc.steps?.length ? { stepsHash: stepsHash(tc.steps) } : {}),
655
671
  })
656
672
  }
657
673
  // An unfinished job (not one that can be resumed) leaves no failing test among the tests: it goes to needs-attention.
package/src/code/notes.ts CHANGED
@@ -98,7 +98,8 @@ function goalWords(goal: string): Set<string> {
98
98
 
99
99
  /** The website a goal names, if any: the same steps on another site are another flow. */
100
100
  function goalHost(goal: string): string {
101
- return /https?:\/\/(?:www\.)?([^/\s,)]+)/i.exec(goal)?.[1]?.toLowerCase() || ""
101
+ // A URL ending a sentence ("Open https://shop.test.") keeps its host without the full stop.
102
+ return (/https?:\/\/(?:www\.)?([^/\s,)]+)/i.exec(goal)?.[1]?.toLowerCase() || "").replace(/[.:;!?]+$/, "")
102
103
  }
103
104
 
104
105
  /**
@@ -147,5 +148,51 @@ export function findWrittenTest(memoryDir: string, repo: string, goal: string, e
147
148
  return best?.test || null
148
149
  }
149
150
 
151
+ /**
152
+ * Goals at least this alike may be the same journey, or one a request extends. ForgeQE's agent rewrites the user's
153
+ * request each time, so the same request scores as low as 0.34 against an earlier wording, while unrelated flows
154
+ * measured on real goals stay under 0.21. Too loose to decide on its own: ForgeQE's agent compares the goals.
155
+ */
156
+ export const RELATED_GOAL = 0.25
157
+ /** And they share at least this many content words: two short goals on one site share a word or two by chance. */
158
+ const RELATED_WORDS = 3
159
+
160
+ function sharedWords(a: string, b: string): number {
161
+ const y = goalWords(b)
162
+ let n = 0
163
+ for (const w of goalWords(a)) if (y.has(w)) n++
164
+ return n
165
+ }
166
+
167
+ /**
168
+ * Earlier tests in this repository whose goals are close to `goal` without being the same flow: candidates to reuse or
169
+ * extend before the browser explores anything. One per test file, the closest first.
170
+ */
171
+ export function findRelatedTests(memoryDir: string, repo: string, goal: string, exists: (file: string) => boolean, max = 3): (WrittenTest & { similarity: number })[] {
172
+ const best = new Map<string, WrittenTest & { similarity: number }>()
173
+ for (const t of load(memoryDir).repos[repo]?.tests || []) {
174
+ const similarity = goalSimilarity(t.goal, goal)
175
+ if (similarity < RELATED_GOAL || sharedWords(t.goal, goal) < RELATED_WORDS || !exists(t.test_file)) continue
176
+ const had = best.get(t.test_file)
177
+ if (!had || similarity > had.similarity || (similarity === had.similarity && t.at > had.at)) best.set(t.test_file, { ...t, similarity })
178
+ }
179
+ return [...best.values()].sort((a, b) => b.similarity - a.similarity || b.at.localeCompare(a.at)).slice(0, max)
180
+ }
181
+
182
+ /** Every test earlier jobs wrote in this repository, of any age: their commands show how it runs one test. */
183
+ export function writtenTests(memoryDir: string, repo: string): WrittenTest[] {
184
+ return load(memoryDir).repos[repo]?.tests || []
185
+ }
186
+
187
+ /** The earlier test written in this repository to this file (the newest), for a test ForgeQE's agent names. */
188
+ export function writtenTestFor(memoryDir: string, repo: string, testFile: string): WrittenTest | null {
189
+ const file = testFile.replace(/^\.\//, "")
190
+ const tests = (load(memoryDir).repos[repo]?.tests || []).filter((t) => t.test_file === file)
191
+ return tests.sort((a, b) => b.at.localeCompare(a.at))[0] || null
192
+ }
193
+
150
194
  /** Where browser_run leaves the earlier test for a goal that failed, so write_test updates it instead of adding one. */
151
195
  export const EXISTING_TEST_FILE = "existing-test.json"
196
+
197
+ /** Where browser_run leaves the earlier test ForgeQE's agent chose to extend, so write_test extends it. */
198
+ export const EXTEND_TEST_FILE = "extend-test.json"
@@ -78,7 +78,7 @@ export type ExistingTest = { testFile: string; command: string; output: string;
78
78
  * An existing test the job works from, chosen from measured coverage (or by the person): `reuse` it as it is, or
79
79
  * `extend` it with only the steps and checks it lacks.
80
80
  */
81
- export type TestTarget = { mode: "reuse" | "extend" | "update"; testFile: string; test: string; line: number; covered: string[]; missing: string[]; why: string }
81
+ export type TestTarget = { mode: "reuse" | "extend" | "update" | "sibling"; testFile: string; test: string; line: number; covered: string[]; missing: string[]; why: string }
82
82
 
83
83
  function targetSection(t: TestTarget): string[] {
84
84
  const list = (items: string[]) => items.map((i) => `- ${i}`)
@@ -91,6 +91,18 @@ function targetSection(t: TestTarget): string[] {
91
91
  "",
92
92
  ]
93
93
  }
94
+ if (t.mode === "sibling") {
95
+ return [
96
+ "## Add a test beside the existing one, do not change it",
97
+ "",
98
+ `${t.test} (${t.testFile}:${t.line}) covers the start of this journey (${t.why}). Add one new test method next to it, in the same class, for the flow below: reuse its setup, its helpers, and the page-object methods it calls for the steps the two share, and add only what the flow adds. Leave ${t.test} as it is. Do not create a new test file.`,
99
+ ...(t.covered.length ? ["", "The steps it already has, to reuse through the same methods:", ...t.covered.map((i) => `- ${i}`)] : []),
100
+ ...(t.missing.length ? ["", "What the new test adds:", ...t.missing.map((i) => `- ${i}`)] : []),
101
+ "",
102
+ "Add page-object members for elements the repository does not locate yet. Do not change what an existing member or method does: other tests use it (code_references shows which).",
103
+ "",
104
+ ]
105
+ }
94
106
  if (t.mode === "reuse") {
95
107
  return [
96
108
  "## The existing test that already covers this flow",
@@ -100,16 +112,24 @@ function targetSection(t: TestTarget): string[] {
100
112
  "",
101
113
  ]
102
114
  }
115
+ const measured = t.covered.length
116
+ ? [
117
+ `ForgeQE's code graph found that ${t.test} (${t.testFile}:${t.line}) already covers most of this flow (${t.why}). Extend that test: add only what it lacks, in the order of the flow, through the same page objects and helpers. Keep every step and assertion it has. Do not create a new test file.`,
118
+ "",
119
+ "It already covers:",
120
+ ...list(t.covered),
121
+ "",
122
+ "Add only these:",
123
+ ...list(t.missing),
124
+ ]
125
+ : [
126
+ `${t.testFile} was chosen to extend (${t.why}): it covers the start of this journey. ForgeQE's code graph could not tell which of the run's steps it already does, so read it, keep every step and assertion it has, and add only the steps and checks of the run it lacks, in the order of the flow, through the same page objects and helpers. Do not create a new test file.`,
127
+ ...(t.missing.length ? ["", "The run's steps and checks:", ...list(t.missing)] : []),
128
+ ]
103
129
  return [
104
130
  "## Extend the existing test, do not write a new one",
105
131
  "",
106
- `ForgeQE's code graph found that ${t.test} (${t.testFile}:${t.line}) already covers most of this flow (${t.why}). Extend that test: add only what it lacks, in the order of the flow, through the same page objects and helpers. Keep every step and assertion it has. Do not create a new test file.`,
107
- "",
108
- "It already covers:",
109
- ...list(t.covered),
110
- "",
111
- "Add only these:",
112
- ...list(t.missing),
132
+ ...measured,
113
133
  "",
114
134
  "When the missing steps start from a different place than the test does, add one test method next to it in the same class, reusing its setup and helpers, instead of a new file.",
115
135
  "Add page-object members for elements the repository does not locate yet. Do not change what an existing member or method does: other tests use it (code_references shows which). When one must change, keep its behaviour for those tests and say so in notes.",
@@ -142,13 +162,15 @@ export function firstPrompt(handoff: Handoff, opts: { liveBrowserDir?: string; l
142
162
  : ""
143
163
  return [
144
164
  opts.existing
145
- ? `Update the existing test for the flow below so it passes again. When it is updated, give the structured result; ForgeQE then runs it.`
165
+ ? `Update the existing test so it does the flow below and passes. When it is updated, give the structured result; ForgeQE then runs it.`
146
166
  : opts.target?.mode === "reuse"
147
167
  ? `An existing test already covers the flow below. Report how to run it; change nothing. ForgeQE then runs it.`
148
168
  : opts.target?.mode === "extend"
149
169
  ? `Extend the existing test named below with what the flow below adds. When it is extended, give the structured result; ForgeQE then runs it.`
150
170
  : opts.target?.mode === "update"
151
171
  ? `Update the existing test named below to match the flow below. When it is updated, give the structured result; ForgeQE then runs it.`
172
+ : opts.target?.mode === "sibling"
173
+ ? `Add a test for the flow below next to the existing test named below, in its class, reusing its setup and steps. When it is written, give the structured result; ForgeQE then runs it.`
152
174
  : `Write a test in this repository for the flow below, reusing the repository's existing test code. When it is written, give the structured result; ForgeQE then runs it.`,
153
175
  "",
154
176
  ...(opts.existing ? existingSection(opts.existing) : opts.target ? targetSection(opts.target) : []),
@@ -0,0 +1,103 @@
1
+ import { readFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import { parseFile, type FileFacts, type Member } from "./graph/parse.ts"
5
+ import type { TestTarget } from "./prompt.ts"
6
+
7
+ /**
8
+ * What a job may do to the existing test it was given, checked in code rather than left to the prompt: extending keeps
9
+ * every line the test had, in order, and only adds; a test added beside it, or a test reused, leaves it as it was; and
10
+ * the test keeps its name. A live run showed why: asked to extend, Scribe changed the test's data values to the run's,
11
+ * and the reviewer approved it.
12
+ */
13
+
14
+ /** The existing test as the job found it: its method name and its lines. */
15
+ export type Original = { file: string; test: string; method: string; lines: string[] }
16
+
17
+ /** A line that is only punctuation says nothing about what the test does: braces, a lone semicolon, a closing paren. */
18
+ const SAYS_NOTHING = /^[{}()[\];,]*$/
19
+
20
+ const meaningful = (lines: string[]) => lines.map((l) => l.trim()).filter((l) => l && !SAYS_NOTHING.test(l))
21
+
22
+ function members(facts: FileFacts | null): { cls?: string; m: Member }[] {
23
+ if (!facts) return []
24
+ return [...facts.classes.flatMap((c) => c.members.map((m) => ({ cls: c.name, m }))), ...facts.functions.map((m) => ({ m }))]
25
+ }
26
+
27
+ /** The test's member in a parsed file: by Class.Method, or by its method name or title alone. */
28
+ function find(facts: FileFacts | null, test: string): Member | null {
29
+ const method = test.split(".").pop() || test
30
+ const all = members(facts).filter(({ m }) => m.kind === "test" || m.kind === "method")
31
+ return all.find(({ cls, m }) => `${cls}.${m.name}` === test)?.m || all.find(({ m }) => m.name === method)?.m || null
32
+ }
33
+
34
+ async function read(repo: string, file: string): Promise<{ text: string; facts: FileFacts | null } | null> {
35
+ try {
36
+ const text = readFileSync(path.join(repo, file), "utf8")
37
+ return { text, facts: await parseFile(file, text) }
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ /** The target test's lines before the job starts; null when the file or the test cannot be read. */
44
+ export async function readOriginal(repo: string, target: Pick<TestTarget, "testFile" | "test">): Promise<Original | null> {
45
+ const got = await read(repo, target.testFile)
46
+ const m = got && find(got.facts, target.test)
47
+ if (!got || !m) return null
48
+ return { file: target.testFile, test: target.test, method: m.name, lines: got.text.split("\n").slice(m.line - 1, m.endLine) }
49
+ }
50
+
51
+ /** Lines of `before` missing from `after` when `after` must keep them in order (it may add lines between them). */
52
+ export function droppedLines(before: string[], after: string[]): string[] {
53
+ const keep = meaningful(before)
54
+ const now = meaningful(after)
55
+ const dropped: string[] = []
56
+ let j = 0
57
+ for (const line of keep) {
58
+ const at = now.indexOf(line, j)
59
+ if (at === -1) dropped.push(line)
60
+ else j = at + 1
61
+ }
62
+ return dropped
63
+ }
64
+
65
+ export type Breach = { why: string; lines: string[] }
66
+
67
+ /**
68
+ * How the job broke what its mode allows on the original test, or null when it did not. extend: no line dropped or
69
+ * changed; sibling and reuse: the test exactly as it was; every mode: the test still there under its name.
70
+ */
71
+ export async function checkOriginal(repo: string, original: Original, mode: TestTarget["mode"]): Promise<Breach | null> {
72
+ if (mode === "update") return null
73
+ const got = await read(repo, original.file)
74
+ const m = got && find(got.facts, original.method)
75
+ if (!got || !m) return { why: `the existing test ${original.test} is no longer in ${original.file} under that name`, lines: [] }
76
+ const now = got.text.split("\n").slice(m.line - 1, m.endLine)
77
+ if (mode === "extend") {
78
+ const dropped = droppedLines(original.lines, now)
79
+ return dropped.length ? { why: `lines of the existing test ${original.test} were changed or removed; extending keeps every line it had and only adds`, lines: dropped } : null
80
+ }
81
+ const changed = meaningful(original.lines).join("\n") !== meaningful(now).join("\n")
82
+ if (!changed) return null
83
+ const dropped = droppedLines(original.lines, now)
84
+ return {
85
+ why: mode === "sibling" ? `the existing test ${original.test} was changed; a test added beside it leaves it as it was` : `the existing test ${original.test} was changed; reusing it changes nothing`,
86
+ lines: dropped.length ? dropped : meaningful(now).filter((l) => !meaningful(original.lines).includes(l)),
87
+ }
88
+ }
89
+
90
+ /** The task for Scribe's next attempt: put the original test back as the mode requires. */
91
+ export function breachPrompt(original: Original, b: Breach, mode: TestTarget["mode"]): string {
92
+ return [
93
+ `ForgeQE checked the existing test and stopped before running it: ${b.why}.`,
94
+ ...(b.lines.length ? ["", mode === "extend" ? "These lines of it are missing or changed; put them back exactly as they were:" : "The test as it was had these lines:", "", "```", ...b.lines.slice(0, 20), "```"] : []),
95
+ "",
96
+ mode === "extend"
97
+ ? `Keep every step, value, and assertion of ${original.test} as it was, and add only the new steps and checks around them. When a value in the run differs from the test's, keep the test's value: the run's data was an example.`
98
+ : mode === "sibling"
99
+ ? `Leave ${original.test} as it was, and put the new flow in a new test method beside it in the same class.`
100
+ : `Leave ${original.test} as it was.`,
101
+ "Then give the structured result again.",
102
+ ].join("\n")
103
+ }
@@ -133,6 +133,8 @@ export function reviewPrompt(input: ReviewInput, repo?: string): string {
133
133
  "",
134
134
  input.target.mode === "extend"
135
135
  ? `The job was asked to extend the existing test ${input.target.test} (${input.target.testFile}) with only what it lacked: ${input.target.missing.join("; ")}. Adding those steps and checks to that test is the requested change, not a rewrite. A step or assertion of it removed or weakened, those steps duplicated in a new test file, or a shared page-object member changed in a way its other users would notice, is a correctness issue.`
136
+ : input.target.mode === "sibling"
137
+ ? `The job was asked to add a test beside the existing test ${input.target.test} (${input.target.testFile}), in the same class, reusing its setup and the page-object methods for the steps they share. A change to ${input.target.test} itself, a new test file, or the shared steps written again instead of reused, is a correctness issue.`
136
138
  : input.target.mode === "update"
137
139
  ? `The user asked to update the existing test ${input.target.test} (${input.target.testFile}) to match this run. Changing its steps, locators, or expected values to match the run is the requested change. Removing an assertion the run still supports, or writing a new test file instead, is a correctness issue.`
138
140
  : `The job was asked to reuse the existing test ${input.target.test} (${input.target.testFile}) as it is, since it already covers the flow. Any change to it, or a new test, is a correctness issue.`,
@@ -9,7 +9,8 @@ import { entraGateway } from "../entra.ts"
9
9
  import { missingKey, type LlmProfile } from "../llm.ts"
10
10
  import type { CodeEvent, FileAction, PlanItem, Usage } from "../types.ts"
11
11
  import { envRefs, withoutNames } from "../childEnv.ts"
12
- import { addUsage, recordUsage, type Role } from "../usage.ts"
12
+ import { recordCall } from "../tokenomics/ledger.ts"
13
+ import { addUsage, type Role } from "../usage.ts"
13
14
  import { linkSkills, type Skill, type SkillAgent } from "../skills/registry.ts"
14
15
  import { EXE, githubDigest, installTool, isMusl, toolPath, type Log } from "../tools.ts"
15
16
  import { RESULT_SCHEMA, testAgentPrompt, type AgentReport } from "./prompt.ts"
@@ -648,6 +649,14 @@ export async function openSession<R = AgentReport>(opts: {
648
649
  const said = new Set<string>()
649
650
  /** Tool calls already said to have started, by part id. */
650
651
  const startedCalls = new Set<string>()
652
+ /** Per model call (assistant message): when it started and the tools it asked for, for the usage ledger. */
653
+ const steps = new Map<string, { started: number; tools: string[]; answered?: number }>()
654
+ const finished = new Set<string>()
655
+ const stepOf = (messageID: string) => {
656
+ let st = steps.get(messageID)
657
+ if (!st) steps.set(messageID, (st = { started: Date.now(), tools: [] }))
658
+ return st
659
+ }
651
660
  let idle: (() => void) | null = null
652
661
  let sessionError = ""
653
662
  // The turn's result comes from events: in agent runtime 1.18 listing a session's messages fails once a prompt used `format`.
@@ -668,6 +677,13 @@ export async function openSession<R = AgentReport>(opts: {
668
677
  break
669
678
  case "message.part.updated": {
670
679
  const part = p.part
680
+ if (part?.type === "step-start" && part.messageID) stepOf(part.messageID).started = Date.now()
681
+ if (part?.type === "tool" && part.messageID && part.tool) {
682
+ const st = stepOf(part.messageID)
683
+ if (!st.tools.includes(part.tool)) st.tools.push(part.tool)
684
+ // The model's part of the step ends when it hands over to a tool; the tool's own time is not the model's.
685
+ st.answered ??= Date.now()
686
+ }
671
687
  // A call is said as soon as it starts, so a long one (an API query, a test run) is visible while it runs.
672
688
  if (part?.type === "tool" && part.state?.status === "running" && part.id && !startedCalls.has(part.id) && !QUIET_TOOLS.has(part.tool) && !opts.hideTool?.(part.tool)) {
673
689
  startedCalls.add(part.id)
@@ -705,7 +721,8 @@ export async function openSession<R = AgentReport>(opts: {
705
721
  turn.text.push(text)
706
722
  emit({ type: "code", kind: "say", text: clip(text, 400) })
707
723
  }
708
- } else if (part?.type === "step-finish" && part.tokens) {
724
+ } else if (part?.type === "step-finish" && part.tokens && !(part.id && finished.has(part.id))) {
725
+ if (part.id) finished.add(part.id)
709
726
  const t = part.tokens
710
727
  const step: Usage = {
711
728
  input: (t.input || 0) + (t.cache?.read || 0) + (t.cache?.write || 0),
@@ -714,7 +731,22 @@ export async function openSession<R = AgentReport>(opts: {
714
731
  ...(t.cache?.write ? { cacheWrite: t.cache.write } : {}),
715
732
  }
716
733
  Object.assign(usage, addUsage(usage, step))
717
- recordUsage(role, step, price)
734
+ const st = part.messageID ? steps.get(part.messageID) : undefined
735
+ if (part.messageID) steps.delete(part.messageID)
736
+ recordCall({
737
+ agent: role,
738
+ usage: step,
739
+ price,
740
+ provider: model.providerID,
741
+ model: model.modelID,
742
+ session: session.id,
743
+ ...(opts.title ? { task: opts.title } : {}),
744
+ ...(st ? { ms: (st.answered ?? Date.now()) - st.started, tools: st.tools } : {}),
745
+ ...(t.reasoning ? { reasoning: t.reasoning } : {}),
746
+ outcome: part.reason === "error" ? "error" : "ok",
747
+ // With code.model the runtime prices the call itself; the profile's price is unknown then.
748
+ ...(typeof part.cost === "number" && part.cost > 0 ? { knownCost: { amount: part.cost, currency: "USD" } } : {}),
749
+ })
718
750
  emit({ type: "code", kind: "usage", usage: { ...usage }, context: step.input })
719
751
  }
720
752
  break