@capagents/forgeqe 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HOW_TO_USE.md +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +781 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +146 -0
- package/src/update.ts +311 -0
- 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
|
+
}
|