@capagents/forgeqe 0.0.0-stage → 1.0.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 (220) hide show
  1. package/HOW_TO_USE.md +1639 -0
  2. package/README.md +338 -2
  3. package/bin/forgeqe.d.ts +3 -0
  4. package/bin/forgeqe.js +188 -0
  5. package/docs/ARCHITECTURE.md +408 -0
  6. package/docs/security-data-flow.html +1870 -0
  7. package/docs/user-guide.html +2094 -0
  8. package/evals/domain/access-role-change.yaml +37 -0
  9. package/evals/domain/banking-payment-transfer.yaml +41 -0
  10. package/evals/domain/gdpr-account-erasure.yaml +30 -0
  11. package/evals/domain/insurance-claim-fnol.yaml +30 -0
  12. package/evals/domain/insurance-policy-renewal.yaml +37 -0
  13. package/evals/domain/lending-loan-application.yaml +36 -0
  14. package/forgeqe-architecture.html +718 -0
  15. package/forgeqe.example.yaml +203 -0
  16. package/knowledge/README.md +18 -0
  17. package/knowledge/business-rules.md +10 -0
  18. package/knowledge/compliance.md +16 -0
  19. package/knowledge/environments.md +5 -0
  20. package/knowledge/glossary.md +5 -0
  21. package/knowledge/product.md +16 -0
  22. package/knowledge/roles.md +9 -0
  23. package/knowledge/test-data.md +7 -0
  24. package/llms.example.json +51 -0
  25. package/package.json +63 -4
  26. package/prompts/README.md +34 -0
  27. package/prompts/api-scenarios.md +21 -0
  28. package/prompts/api-tests.md +39 -0
  29. package/prompts/brain.md +166 -0
  30. package/prompts/browser-capture.md +7 -0
  31. package/prompts/browser-case-report.md +7 -0
  32. package/prompts/browser-checks.md +14 -0
  33. package/prompts/browser-efficiency.md +7 -0
  34. package/prompts/browser-error-recovery.md +15 -0
  35. package/prompts/browser-extra.md +5 -0
  36. package/prompts/browser-goal-verification.md +13 -0
  37. package/prompts/browser-skill-select.md +6 -0
  38. package/prompts/domain-rule.md +13 -0
  39. package/prompts/eval-judge.md +19 -0
  40. package/prompts/explore-charters.md +27 -0
  41. package/prompts/find-tests.md +28 -0
  42. package/prompts/heal.md +40 -0
  43. package/prompts/pr-review.md +29 -0
  44. package/prompts/skill-rule.md +6 -0
  45. package/prompts/test-agent.md +34 -0
  46. package/prompts/test-reviewer.md +30 -0
  47. package/prompts/test-rules.md +28 -0
  48. package/py/forgeqe_engine/__init__.py +3 -0
  49. package/py/forgeqe_engine/__main__.py +3 -0
  50. package/py/forgeqe_engine/brand.py +453 -0
  51. package/py/forgeqe_engine/brand_assets.py +6 -0
  52. package/py/forgeqe_engine/browser.py +196 -0
  53. package/py/forgeqe_engine/capture.py +538 -0
  54. package/py/forgeqe_engine/checks.py +98 -0
  55. package/py/forgeqe_engine/cli.py +188 -0
  56. package/py/forgeqe_engine/diagnostics.py +609 -0
  57. package/py/forgeqe_engine/llm.py +195 -0
  58. package/py/forgeqe_engine/logins.py +251 -0
  59. package/py/forgeqe_engine/loops.py +224 -0
  60. package/py/forgeqe_engine/memory.py +1004 -0
  61. package/py/forgeqe_engine/network.py +244 -0
  62. package/py/forgeqe_engine/prompts.py +20 -0
  63. package/py/forgeqe_engine/replay.py +240 -0
  64. package/py/forgeqe_engine/report.py +173 -0
  65. package/py/forgeqe_engine/service.py +1335 -0
  66. package/py/forgeqe_engine/settings.py +93 -0
  67. package/py/forgeqe_engine/skills.py +88 -0
  68. package/py/forgeqe_engine/summary.py +239 -0
  69. package/py/forgeqe_engine/urls.py +81 -0
  70. package/py/forgeqe_engine/workflow_yaml.py +689 -0
  71. package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
  72. package/skills/dynamics-365/SKILL.md +21 -0
  73. package/skills/playwright-typescript/SKILL.md +35 -0
  74. package/skills/writing-skills/SKILL.md +30 -0
  75. package/src/api/plan.ts +246 -0
  76. package/src/api/run.ts +204 -0
  77. package/src/api/scenario.ts +502 -0
  78. package/src/api/schema.ts +170 -0
  79. package/src/api/spec.ts +624 -0
  80. package/src/api/store.ts +232 -0
  81. package/src/api/traffic.ts +281 -0
  82. package/src/api/types.ts +163 -0
  83. package/src/api/write.ts +272 -0
  84. package/src/auth/signin.ts +494 -0
  85. package/src/auth/store.ts +170 -0
  86. package/src/brain/index.ts +189 -0
  87. package/src/brain/prompt.ts +19 -0
  88. package/src/capabilities/docs.ts +101 -0
  89. package/src/capabilities/guide.ts +455 -0
  90. package/src/capabilities/registry.ts +343 -0
  91. package/src/capabilities/render.ts +106 -0
  92. package/src/capabilities/status.ts +137 -0
  93. package/src/card.ts +11 -0
  94. package/src/cases/ado.ts +325 -0
  95. package/src/cases/api.ts +117 -0
  96. package/src/cases/bugs.ts +223 -0
  97. package/src/cases/confluence.ts +111 -0
  98. package/src/cases/context.ts +223 -0
  99. package/src/cases/dashboards.ts +137 -0
  100. package/src/cases/failure.ts +97 -0
  101. package/src/cases/files.ts +295 -0
  102. package/src/cases/goal.ts +53 -0
  103. package/src/cases/http.ts +111 -0
  104. package/src/cases/index.ts +131 -0
  105. package/src/cases/jira.ts +122 -0
  106. package/src/cases/qtest.ts +108 -0
  107. package/src/cases/quality.ts +190 -0
  108. package/src/cases/rag.ts +100 -0
  109. package/src/cases/readiness.ts +284 -0
  110. package/src/cases/save.ts +149 -0
  111. package/src/cases/sprints.ts +148 -0
  112. package/src/cases/testrail.ts +176 -0
  113. package/src/cases/types.ts +107 -0
  114. package/src/cases/wiki.ts +105 -0
  115. package/src/cases/xray.ts +206 -0
  116. package/src/cases/zephyr.ts +153 -0
  117. package/src/childEnv.ts +86 -0
  118. package/src/cli/args.ts +114 -0
  119. package/src/cli/help.ts +167 -0
  120. package/src/cli/index.ts +1142 -0
  121. package/src/cli/terminal.ts +187 -0
  122. package/src/code/areas.ts +337 -0
  123. package/src/code/attention.ts +214 -0
  124. package/src/code/find.ts +540 -0
  125. package/src/code/handoff.ts +541 -0
  126. package/src/code/history.ts +170 -0
  127. package/src/code/impact.ts +231 -0
  128. package/src/code/index.ts +34 -0
  129. package/src/code/livebrowser.ts +67 -0
  130. package/src/code/loop.ts +603 -0
  131. package/src/code/notes.ts +75 -0
  132. package/src/code/profile.ts +226 -0
  133. package/src/code/prompt.ts +303 -0
  134. package/src/code/prreview.ts +649 -0
  135. package/src/code/prtests.ts +103 -0
  136. package/src/code/replay.ts +772 -0
  137. package/src/code/review.ts +202 -0
  138. package/src/code/runtime.ts +757 -0
  139. package/src/code/slices.ts +159 -0
  140. package/src/code/testrun.ts +187 -0
  141. package/src/code/traceability.ts +100 -0
  142. package/src/code/worktree.ts +153 -0
  143. package/src/commands.ts +101 -0
  144. package/src/config.ts +970 -0
  145. package/src/detail.ts +65 -0
  146. package/src/domain.ts +19 -0
  147. package/src/engine/bridge.ts +359 -0
  148. package/src/engine/mock.ts +102 -0
  149. package/src/engine/setup.ts +298 -0
  150. package/src/entra.ts +154 -0
  151. package/src/env.ts +24 -0
  152. package/src/evals/domain.ts +338 -0
  153. package/src/explore/charters.ts +151 -0
  154. package/src/explore/run.ts +280 -0
  155. package/src/explore/story.ts +106 -0
  156. package/src/guard.ts +51 -0
  157. package/src/heal/loop.ts +357 -0
  158. package/src/heal/prompt.ts +143 -0
  159. package/src/heal/report.ts +95 -0
  160. package/src/index.ts +12 -0
  161. package/src/init.ts +102 -0
  162. package/src/knowledge.ts +114 -0
  163. package/src/legacyEnv.ts +9 -0
  164. package/src/llm.ts +61 -0
  165. package/src/locale.ts +25 -0
  166. package/src/logins.ts +148 -0
  167. package/src/managed.ts +101 -0
  168. package/src/mcp/apitools.ts +335 -0
  169. package/src/mcp/captools.ts +25 -0
  170. package/src/mcp/casetools.ts +223 -0
  171. package/src/mcp/commands.ts +421 -0
  172. package/src/mcp/exploretools.ts +129 -0
  173. package/src/mcp/external.ts +172 -0
  174. package/src/mcp/git.ts +78 -0
  175. package/src/mcp/notifytools.ts +49 -0
  176. package/src/mcp/prtools.ts +261 -0
  177. package/src/mcp/qualitytools.ts +154 -0
  178. package/src/mcp/repotests.ts +286 -0
  179. package/src/mcp/server.ts +234 -0
  180. package/src/mcp/tools.ts +810 -0
  181. package/src/memory.ts +250 -0
  182. package/src/money.ts +39 -0
  183. package/src/monitor/alerts.ts +94 -0
  184. package/src/monitor/background.ts +68 -0
  185. package/src/monitor/run.ts +263 -0
  186. package/src/monitor/state.ts +105 -0
  187. package/src/notify/events.ts +144 -0
  188. package/src/notify/notice.ts +177 -0
  189. package/src/prices.ts +175 -0
  190. package/src/print.ts +54 -0
  191. package/src/privacy.ts +35 -0
  192. package/src/prompts.ts +165 -0
  193. package/src/quality/budgets.ts +249 -0
  194. package/src/quality/history.ts +149 -0
  195. package/src/quality/visual.ts +201 -0
  196. package/src/report/data.ts +475 -0
  197. package/src/report/html.ts +106 -0
  198. package/src/report/template.html +84 -0
  199. package/src/run.ts +243 -0
  200. package/src/sessionLog.ts +216 -0
  201. package/src/sessionLogins.ts +94 -0
  202. package/src/skills/registry.ts +217 -0
  203. package/src/skills/scaffold.ts +65 -0
  204. package/src/skills/select.ts +37 -0
  205. package/src/skills/view.ts +44 -0
  206. package/src/suite/report.ts +135 -0
  207. package/src/suite/run.ts +303 -0
  208. package/src/summary.ts +188 -0
  209. package/src/tools.ts +130 -0
  210. package/src/tui/App.tsx +983 -0
  211. package/src/tui/clipboard.ts +36 -0
  212. package/src/tui/components.tsx +263 -0
  213. package/src/tui/dialogs.ts +65 -0
  214. package/src/tui/main.tsx +20 -0
  215. package/src/tui/text.ts +28 -0
  216. package/src/tui/theme.ts +104 -0
  217. package/src/tui/transcript.tsx +344 -0
  218. package/src/types.ts +136 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
@@ -0,0 +1,338 @@
1
+ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+
5
+ import YAML from "yaml"
6
+
7
+ import { openBrain, type BrainOptions } from "../brain/index.ts"
8
+ import { openSession, type OpencodeSession } from "../code/runtime.ts"
9
+ import type { AppConfig, DomainConfig } from "../config.ts"
10
+ import { prompt } from "../prompts.ts"
11
+ import type { CodeEvent, Usage } from "../types.ts"
12
+
13
+ /**
14
+ * Whether ForgeQE's agent brings domain and regulatory knowledge to test design: each case is a realistic story whose
15
+ * acceptance criteria leave the industry's risks and the regulations' obligations unstated. ForgeQE's own agent writes
16
+ * test cases for it, once with the domain declared (domain: and a compliance file, as a team would set them) and once
17
+ * without; a separate judge session scores both against what a domain expert expects them to cover, and lists every
18
+ * control or article id they cite, so ids not in the team's compliance file show up as invented.
19
+ */
20
+
21
+ export const DOMAIN_CASES = path.resolve(import.meta.dir, "..", "..", "evals", "domain")
22
+
23
+ export type Source = "story" | "domain" | "regulation"
24
+ export type Expectation = { id: string; source: Source; covers: string }
25
+ export type DomainCase = {
26
+ id: string
27
+ title: string
28
+ domain: DomainConfig
29
+ /** The team's compliance file for this product (markdown), when it has one. */
30
+ compliance?: string
31
+ story: { title: string; description: string; acceptance: string }
32
+ expect: Expectation[]
33
+ }
34
+ export type Variant = "declared" | "undeclared"
35
+ export const VARIANTS: Variant[] = ["declared", "undeclared"]
36
+ const SOURCES: Source[] = ["story", "domain", "regulation"]
37
+
38
+ /** Every case file in a folder (or one file), checked so a broken case fails before any model is called. */
39
+ export function loadDomainCases(where: string): DomainCase[] {
40
+ const files = where.endsWith(".yaml") || where.endsWith(".yml") ? [where] : readdirSync(where).filter((f) => /\.ya?ml$/.test(f)).sort().map((f) => path.join(where, f))
41
+ if (!files.length) throw new Error(`No evaluation cases (.yaml) in ${where}.`)
42
+ const cases = files.map((file) => {
43
+ const raw = YAML.parse(readFileSync(file, "utf8")) as Partial<DomainCase>
44
+ const where = path.basename(file)
45
+ if (!raw?.id || !raw.title || !raw.story?.title || !raw.domain?.industry) throw new Error(`${where}: id, title, domain.industry, and story.title are required.`)
46
+ if (!Array.isArray(raw.expect) || !raw.expect.length) throw new Error(`${where}: expect needs at least one expectation.`)
47
+ for (const e of raw.expect) {
48
+ if (!e?.id || !e.covers || !SOURCES.includes(e.source as Source)) throw new Error(`${where}: each expectation needs id, covers, and source (${SOURCES.join(", ")}).`)
49
+ }
50
+ const ids = raw.expect.map((e) => e.id)
51
+ if (new Set(ids).size !== ids.length) throw new Error(`${where}: expectation ids must be unique.`)
52
+ return {
53
+ id: String(raw.id),
54
+ title: String(raw.title),
55
+ domain: { industry: String(raw.domain.industry), regulations: (raw.domain.regulations || []).map(String), ...(raw.domain.notes ? { notes: String(raw.domain.notes) } : {}) },
56
+ ...(raw.compliance ? { compliance: String(raw.compliance) } : {}),
57
+ story: { title: String(raw.story.title), description: String(raw.story.description || ""), acceptance: String(raw.story.acceptance || "") },
58
+ expect: raw.expect.map((e) => ({ id: String(e.id), source: e.source as Source, covers: String(e.covers) })),
59
+ }
60
+ })
61
+ const dup = cases.map((c) => c.id).filter((id, i, all) => all.indexOf(id) !== i)
62
+ if (dup.length) throw new Error(`Duplicate case ids: ${[...new Set(dup)].join(", ")}.`)
63
+ return cases
64
+ }
65
+
66
+ export function storyRequest(c: DomainCase): string {
67
+ return [
68
+ "Write the test cases for this story, as a test analyst would before testing starts. Do not save them anywhere, run anything, or open a browser: reply with the test cases only, each with a title, steps, and expected results.",
69
+ "",
70
+ `# ${c.story.title}`,
71
+ "",
72
+ c.story.description,
73
+ "",
74
+ "## Acceptance criteria",
75
+ "",
76
+ c.story.acceptance,
77
+ ].join("\n")
78
+ }
79
+
80
+ /** The config the agent runs with: no systems to save into, and the case's domain only when declared. */
81
+ export function caseConfig(base: AppConfig, c: DomainCase, variant: Variant, knowledgeDir: string): AppConfig {
82
+ return {
83
+ ...base,
84
+ mcp: undefined,
85
+ sources: { ...base.sources, ado: undefined, jira: undefined, testrail: undefined, zephyr: undefined, qtest: undefined, confluence: undefined, bugs: undefined, writeBack: false, saveCases: false, xray: { ...base.sources.xray, url: "" } },
86
+ knowledge: { dir: knowledgeDir, browserMaxChars: 0 },
87
+ domain: variant === "declared" ? c.domain : undefined,
88
+ }
89
+ }
90
+
91
+ export const JUDGE_SCHEMA = {
92
+ type: "object",
93
+ properties: {
94
+ items: {
95
+ type: "array",
96
+ items: {
97
+ type: "object",
98
+ properties: { id: { type: "string" }, covered: { type: "boolean" }, evidence: { type: "string" } },
99
+ required: ["id", "covered", "evidence"],
100
+ },
101
+ },
102
+ cited_ids: { type: "array", items: { type: "string" } },
103
+ },
104
+ required: ["items", "cited_ids"],
105
+ } as const
106
+
107
+ export type Judgement = { items: { id: string; covered: boolean; evidence: string }[]; cited_ids: string[] }
108
+
109
+ export function judgeRequest(c: DomainCase, variant: Variant, testCases: string): string {
110
+ return [
111
+ `# Story: ${c.story.title}`,
112
+ c.story.description,
113
+ "",
114
+ "## Acceptance criteria",
115
+ c.story.acceptance,
116
+ "",
117
+ variant === "declared"
118
+ ? `## Declared domain\n${c.domain.industry}; regulations in scope: ${c.domain.regulations.join(", ") || "none named"}.`
119
+ : "## Declared domain\nNone was declared.",
120
+ "",
121
+ variant === "declared" && c.compliance ? `## The team's compliance file\n${c.compliance.trim()}` : "## The team's compliance file\nThere is none.",
122
+ "",
123
+ "## Expectations",
124
+ ...c.expect.map((e) => `- ${e.id}: ${e.covers}`),
125
+ "",
126
+ "## The test cases to judge",
127
+ testCases.trim() || "(the agent wrote none)",
128
+ ].join("\n")
129
+ }
130
+
131
+ export type Coverage = Record<Source, { covered: number; total: number }>
132
+
133
+ export type CaseRun = {
134
+ case: string
135
+ variant: Variant
136
+ repeat: number
137
+ testCases: string
138
+ items: { id: string; source: Source; covered: boolean; evidence: string }[]
139
+ coverage: Coverage
140
+ /** Ids the test cases cite that the team's compliance file does not list. */
141
+ invented: string[]
142
+ cited: string[]
143
+ error?: string
144
+ }
145
+
146
+ export function score(c: DomainCase, variant: Variant, j: Judgement): Pick<CaseRun, "items" | "coverage" | "invented" | "cited"> {
147
+ const items = c.expect.map((e) => {
148
+ const got = j.items.find((i) => i.id === e.id)
149
+ return { id: e.id, source: e.source, covered: Boolean(got?.covered), evidence: got?.evidence || "not judged" }
150
+ })
151
+ const coverage = Object.fromEntries(
152
+ SOURCES.map((s) => {
153
+ const of = items.filter((i) => i.source === s)
154
+ return [s, { covered: of.filter((i) => i.covered).length, total: of.length }]
155
+ }),
156
+ ) as Coverage
157
+ const allowed = variant === "declared" ? c.compliance || "" : ""
158
+ const cited = [...new Set(j.cited_ids.map((x) => x.trim()).filter(Boolean))]
159
+ return { items, coverage, cited, invented: cited.filter((id) => !allowed.includes(id)) }
160
+ }
161
+
162
+ type Opener<R> = (o: Parameters<typeof openSession>[0]) => Promise<OpencodeSession<R>>
163
+
164
+ export type EvalOptions = {
165
+ config: AppConfig
166
+ cases: DomainCase[]
167
+ variants?: Variant[]
168
+ repeat?: number
169
+ /** The judge's config (another profile); default: the agent's. */
170
+ judgeConfig?: AppConfig
171
+ signal?: AbortSignal
172
+ onProgress?: (text: string) => void
173
+ onEvent?: (e: CodeEvent) => void
174
+ /** Replace the agent and the judge sessions (tests). */
175
+ openAgent?: BrainOptions["open"]
176
+ openJudge?: Opener<Judgement>
177
+ }
178
+
179
+ export type EvalReport = {
180
+ at: string
181
+ model: string
182
+ judge: string
183
+ runs: CaseRun[]
184
+ /** Per variant, over every run: coverage by source, and invented ids. */
185
+ summary: Record<Variant, { coverage: Coverage; invented: number; errors: number }>
186
+ usage: Usage
187
+ }
188
+
189
+ const JUDGE_AGENT = {
190
+ name: "forgeqe-judge",
191
+ description: "Scores test cases against what a domain expert expects them to cover",
192
+ get prompt() {
193
+ return prompt("eval-judge")
194
+ },
195
+ permission: { edit: "deny", bash: "deny", webfetch: "deny" },
196
+ }
197
+
198
+ function addUsage(to: Usage, u: Usage) {
199
+ to.input += u.input || 0
200
+ to.output += u.output || 0
201
+ if (u.cached) to.cached = (to.cached || 0) + u.cached
202
+ }
203
+
204
+ export async function runDomainEval(opts: EvalOptions): Promise<EvalReport> {
205
+ const variants = opts.variants?.length ? opts.variants : VARIANTS
206
+ const repeat = Math.max(1, opts.repeat || 1)
207
+ const judgeConfig = opts.judgeConfig || opts.config
208
+ const usage: Usage = { input: 0, output: 0 }
209
+ const runs: CaseRun[] = []
210
+ let model = ""
211
+ let judgeModel = ""
212
+ const total = opts.cases.length * variants.length * repeat
213
+ for (const c of opts.cases) {
214
+ for (const variant of variants) {
215
+ for (let r = 1; r <= repeat; r++) {
216
+ if (opts.signal?.aborted) break
217
+ opts.onProgress?.(`[${runs.length + 1}/${total}] ${c.id} · ${variant}${repeat > 1 ? ` · run ${r}` : ""}`)
218
+ const dir = mkdtempSync(path.join(os.tmpdir(), "wp-eval-"))
219
+ const knowledge = path.join(dir, "knowledge")
220
+ mkdirSync(knowledge)
221
+ if (variant === "declared" && c.compliance) writeFileSync(path.join(knowledge, "compliance.md"), `# Compliance\n\n${c.compliance.trim()}\n`)
222
+ const config = caseConfig(opts.config, c, variant, knowledge)
223
+ let testCases = ""
224
+ try {
225
+ const brain = await openBrain({ config, cwd: dir, context: () => ({ session: "once", writeTests: false }), signal: opts.signal, onEvent: opts.onEvent, open: opts.openAgent, external: { servers: [], skipped: [] } })
226
+ try {
227
+ model = brain.model
228
+ const answer = await brain.ask(storyRequest(c))
229
+ if (answer.outcome === "failed" || answer.outcome === "stopped") throw new Error(answer.error || `the agent ${answer.outcome}`)
230
+ testCases = answer.reply
231
+ } finally {
232
+ addUsage(usage, brain.usage)
233
+ await brain.close()
234
+ }
235
+ const open = (opts.openJudge || openSession) as Opener<Judgement>
236
+ const judge = await open({
237
+ repo: dir,
238
+ profile: judgeConfig.llm,
239
+ // The judge is given the domain as data and must not apply the domain rule itself.
240
+ domain: null,
241
+ role: "judge",
242
+ model: judgeConfig.code.model,
243
+ bin: judgeConfig.code.opencode,
244
+ readDirs: [dir],
245
+ agent: JUDGE_AGENT,
246
+ schema: JUDGE_SCHEMA as unknown as Record<string, unknown>,
247
+ signal: opts.signal,
248
+ title: "ForgeQE evaluation judge",
249
+ })
250
+ try {
251
+ judgeModel = judge.model
252
+ let out = await judge.prompt(judgeRequest(c, variant, testCases))
253
+ if (!out.report && !out.error) out = await judge.prompt("Give the structured result: items and cited_ids.")
254
+ if (!out.report) throw new Error(`the judge gave no result${out.error ? `: ${out.error}` : ""}`)
255
+ runs.push({ case: c.id, variant, repeat: r, testCases, ...score(c, variant, out.report) })
256
+ } finally {
257
+ addUsage(usage, judge.usage)
258
+ await judge.close()
259
+ }
260
+ } catch (err) {
261
+ const empty = score(c, variant, { items: [], cited_ids: [] })
262
+ runs.push({ case: c.id, variant, repeat: r, testCases, ...empty, error: err instanceof Error ? err.message : String(err) })
263
+ } finally {
264
+ rmSync(dir, { recursive: true, force: true })
265
+ }
266
+ }
267
+ }
268
+ }
269
+ return { at: new Date().toISOString(), model, judge: judgeModel, runs, summary: summarise(runs, variants), usage }
270
+ }
271
+
272
+ export function summarise(runs: CaseRun[], variants: Variant[] = VARIANTS): EvalReport["summary"] {
273
+ const out = {} as EvalReport["summary"]
274
+ for (const v of variants) {
275
+ const of = runs.filter((r) => r.variant === v)
276
+ const coverage = Object.fromEntries(
277
+ SOURCES.map((s) => [s, of.reduce((a, r) => ({ covered: a.covered + r.coverage[s].covered, total: a.total + r.coverage[s].total }), { covered: 0, total: 0 })]),
278
+ ) as Coverage
279
+ out[v] = { coverage, invented: of.reduce((n, r) => n + r.invented.length, 0), errors: of.filter((r) => r.error).length }
280
+ }
281
+ return out
282
+ }
283
+
284
+ const pct = (x: { covered: number; total: number }) => (x.total ? `${Math.round((100 * x.covered) / x.total)}%` : "–")
285
+
286
+ /** The report as Markdown: the comparison first, then each case's misses. */
287
+ export function reportMarkdown(r: EvalReport): string {
288
+ const variants = Object.keys(r.summary) as Variant[]
289
+ const lines = [
290
+ `# Domain evaluation`,
291
+ "",
292
+ `${r.at} · agent ${r.model || "?"} · judge ${r.judge || "?"} · ${r.runs.length} runs · ${r.usage.input + r.usage.output} tokens`,
293
+ "",
294
+ `| | ${variants.join(" | ")} |`,
295
+ `|---|${variants.map(() => "---").join("|")}|`,
296
+ ...SOURCES.map((s) => `| ${s} coverage | ${variants.map((v) => pct(r.summary[v].coverage[s])).join(" | ")} |`),
297
+ `| invented ids | ${variants.map((v) => r.summary[v].invented).join(" | ")} |`,
298
+ `| errors | ${variants.map((v) => r.summary[v].errors).join(" | ")} |`,
299
+ "",
300
+ ]
301
+ for (const run of r.runs) {
302
+ lines.push(`## ${run.case} · ${run.variant}${run.repeat > 1 ? ` · run ${run.repeat}` : ""}`)
303
+ if (run.error) lines.push("", `Error: ${run.error}`)
304
+ lines.push("", SOURCES.map((s) => `${s} ${run.coverage[s].covered}/${run.coverage[s].total}`).join(" · "))
305
+ const missed = run.items.filter((i) => !i.covered)
306
+ if (missed.length) lines.push("", "Missed:", ...missed.map((i) => `- ${i.id} (${i.source}): ${i.evidence}`))
307
+ if (run.invented.length) lines.push("", `Invented ids: ${run.invented.join(", ")}`)
308
+ lines.push("")
309
+ }
310
+ return lines.join("\n")
311
+ }
312
+
313
+ /** Writes report.json and report.md under <reports>/evals/domain/<time>/ and returns the folder. */
314
+ export function saveReport(r: EvalReport, outDir: string): string {
315
+ const dir = path.join(outDir, "evals", "domain", r.at.replace(/[:.]/g, "-"))
316
+ mkdirSync(dir, { recursive: true })
317
+ writeFileSync(path.join(dir, "report.json"), JSON.stringify(r, null, 2))
318
+ writeFileSync(path.join(dir, "report.md"), reportMarkdown(r))
319
+ return dir
320
+ }
321
+
322
+ /**
323
+ * The gate for CI: the declared variant covers at least `min` of the domain and regulation expectations, cites no
324
+ * invented ids, and had no errors. Null when it passes, else why not.
325
+ */
326
+ export function gate(r: EvalReport, min: number): string | null {
327
+ const d = r.summary.declared
328
+ if (!d) return "the declared variant did not run"
329
+ const covered = d.coverage.domain.covered + d.coverage.regulation.covered
330
+ const total = d.coverage.domain.total + d.coverage.regulation.total
331
+ const share = total ? covered / total : 1
332
+ const why = [
333
+ share < min ? `domain and regulation coverage ${Math.round(share * 100)}% is under ${Math.round(min * 100)}%` : "",
334
+ d.invented ? `${d.invented} invented id(s)` : "",
335
+ d.errors ? `${d.errors} run(s) failed` : "",
336
+ ].filter(Boolean)
337
+ return why.length ? why.join("; ") : null
338
+ }
@@ -0,0 +1,151 @@
1
+ import type { AppConfig } from "../config.ts"
2
+ import { privateEnvNames } from "../childEnv.ts"
3
+ import type { CodeEvent, Usage } from "../types.ts"
4
+ import { openSession, type AgentSpec, type OpencodeSession } from "../code/runtime.ts"
5
+ import type { Story } from "./story.ts"
6
+ import { domainNotes } from "../domain.ts"
7
+ import { knowledgeBlock, readKnowledge, relevantKnowledge } from "../knowledge.ts"
8
+ import { prompt } from "../prompts.ts"
9
+
10
+ /**
11
+ * Exploratory charters: what to try on a story's feature beyond its scripted test cases. Each charter has a mission (the
12
+ * risk it probes), concrete steps, and an expected result for each step (the oracle: from the acceptance criteria, or
13
+ * what any well-behaved application does, like refusing an invalid email with a message). The browser agent then runs
14
+ * each charter and judges each expected result on the live page.
15
+ */
16
+
17
+ export const CATEGORIES = ["variation", "edge_case", "invalid_input", "boundary", "role", "state", "negative"] as const
18
+ export type Category = (typeof CATEGORIES)[number]
19
+
20
+ export type Charter = {
21
+ id: string
22
+ title: string
23
+ category: Category
24
+ mission: string
25
+ /** The saved login to run as (a role), when the charter is about what a role may do. */
26
+ role?: string
27
+ /** The acceptance criterion it probes, when it probes one. */
28
+ criterion?: string
29
+ steps: { action: string; data?: string; expected: string }[]
30
+ /** Creates, changes, or deletes data in the application. */
31
+ changesData?: boolean
32
+ }
33
+
34
+ export const charterAgentPrompt = () => prompt("explore-charters")
35
+
36
+ export const CHARTER_AGENT: AgentSpec = {
37
+ name: "forgeqe-explore-plan",
38
+ description: "Plans exploratory charters for a user story beyond its scripted test cases",
39
+ get prompt() {
40
+ return charterAgentPrompt()
41
+ },
42
+ permission: { edit: "deny", bash: "deny", webfetch: "deny", task: "deny", "live_*": "deny" },
43
+ }
44
+
45
+ export const CHARTER_SCHEMA = {
46
+ type: "object",
47
+ properties: {
48
+ charters: {
49
+ type: "array",
50
+ items: {
51
+ type: "object",
52
+ properties: {
53
+ title: { type: "string" },
54
+ category: { type: "string", enum: [...CATEGORIES] },
55
+ mission: { type: "string", description: "The risk this charter probes, in one sentence" },
56
+ role: { type: "string", description: "Only for role charters: the login name to run as, from the list" },
57
+ criterion: { type: "string", description: "The acceptance criterion it probes, quoted, when there is one" },
58
+ steps: {
59
+ type: "array",
60
+ items: { type: "object", properties: { action: { type: "string" }, data: { type: "string" }, expected: { type: "string" } }, required: ["action", "expected"] },
61
+ },
62
+ changes_data: { type: "boolean" },
63
+ },
64
+ required: ["title", "category", "mission", "steps"],
65
+ },
66
+ },
67
+ notes: { type: "string" },
68
+ },
69
+ required: ["charters"],
70
+ } as const
71
+
72
+ type CharterReport = { charters: (Omit<Charter, "id" | "changesData"> & { changes_data?: boolean })[]; notes?: string }
73
+
74
+ const clip = (t: string, n: number) => (t.length > n ? `${t.slice(0, n - 1)}…` : t)
75
+
76
+ export function charterPrompt(story: Story, opts: { count: number; roles: string[]; url?: string; knowledge?: string }): string {
77
+ const lines = [`# Story ${story.id === "story" ? "" : `${story.id}: `}${story.title}`]
78
+ if (story.url) lines.push(story.url)
79
+ if (story.description) lines.push("", clip(story.description, 3000))
80
+ if (story.acceptance) lines.push("", "## Acceptance criteria", "", clip(story.acceptance, 3000))
81
+ for (const p of story.parents) lines.push("", `## ${p.type} ${p.id}: ${p.title}`, clip(p.description || "", 800))
82
+ if (opts.knowledge) lines.push("", opts.knowledge)
83
+ if (story.passages.length) lines.push("", "## From the requirements search", "", ...story.passages.map((p) => `- ${clip(p, 600)}`))
84
+ if (story.scripted.length) {
85
+ lines.push("", "## Scripted test cases (already covered: go beyond them)")
86
+ for (const tc of story.scripted) lines.push("", `${tc.id}: ${tc.title}`, ...tc.steps.slice(0, 12).map((s, i) => ` ${i + 1}. ${s.action}${s.expected ? ` → ${s.expected}` : ""}`))
87
+ } else lines.push("", "No scripted test cases are linked to this story yet.")
88
+ lines.push(
89
+ "",
90
+ "## The session",
91
+ "",
92
+ `Start page: ${opts.url || "(not given: the browser agent finds the feature from the story)"}`,
93
+ opts.roles.length ? `Roles (saved logins the browser agent can sign in as): ${opts.roles.join(", ")}. Include role charters for the differences between them.` : "No roles: the browser agent uses the default sign-in. Plan no role charters.",
94
+ "",
95
+ `Plan ${opts.count} exploratory charters.`,
96
+ )
97
+ return lines.join("\n")
98
+ }
99
+
100
+ export async function planCharters(opts: {
101
+ config: AppConfig
102
+ story: Story
103
+ count: number
104
+ roles: string[]
105
+ url?: string
106
+ cwd: string
107
+ signal?: AbortSignal
108
+ onEvent?: (e: CodeEvent) => void
109
+ open?: (o: Parameters<typeof openSession>[0]) => Promise<OpencodeSession<CharterReport>>
110
+ }): Promise<{ charters: Charter[]; notes?: string; usage: Usage; model: string }> {
111
+ const open = (opts.open || openSession) as (o: Parameters<typeof openSession>[0]) => Promise<OpencodeSession<CharterReport>>
112
+ const session = await open({
113
+ repo: opts.cwd,
114
+ profile: opts.config.llm,
115
+ domain: domainNotes(opts.config),
116
+ model: opts.config.code.model,
117
+ bin: opts.config.code.opencode,
118
+ readDirs: [opts.cwd],
119
+ hide: privateEnvNames(opts.config),
120
+ signal: opts.signal,
121
+ onEvent: opts.onEvent,
122
+ agent: CHARTER_AGENT,
123
+ schema: CHARTER_SCHEMA as unknown as Record<string, unknown>,
124
+ title: "ForgeQE exploratory charters",
125
+ })
126
+ try {
127
+ const k = opts.config.knowledge
128
+ const knowledge = k ? knowledgeBlock(relevantKnowledge(readKnowledge(k.dir), `${opts.story.title}\n${opts.story.description}\n${opts.story.acceptance}\n${opts.roles.join(" ")}`, 6000)) : ""
129
+ let reply = await session.prompt(charterPrompt(opts.story, { ...opts, knowledge }))
130
+ if (!reply.report && !reply.error && !opts.signal?.aborted) reply = await session.prompt("Give the structured result: charters.")
131
+ if (!reply.report?.charters?.length) throw new Error(`No charters were planned${reply.error ? `: ${reply.error}` : "."}`)
132
+ const roles = new Set(opts.roles)
133
+ const charters = reply.report.charters
134
+ .filter((c) => c.steps?.length)
135
+ .slice(0, opts.count)
136
+ .map((c, i): Charter => ({
137
+ id: `C${i + 1}`,
138
+ title: c.title,
139
+ category: (CATEGORIES as readonly string[]).includes(c.category) ? c.category : "variation",
140
+ mission: c.mission,
141
+ // A role that is not a saved login cannot be signed in as: the charter runs with the default sign-in.
142
+ ...(c.role && roles.has(c.role) ? { role: c.role } : {}),
143
+ ...(c.criterion ? { criterion: c.criterion } : {}),
144
+ steps: c.steps.slice(0, 8).map((s) => ({ action: s.action, ...(s.data ? { data: s.data } : {}), expected: s.expected })),
145
+ ...(c.changes_data ? { changesData: true } : {}),
146
+ }))
147
+ return { charters, ...(reply.report.notes ? { notes: reply.report.notes } : {}), usage: { ...session.usage }, model: session.model }
148
+ } finally {
149
+ await session.close()
150
+ }
151
+ }