@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.
- 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 +757 -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 +136 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmdirSync, statSync, symlinkSync, unlinkSync } from "node:fs"
|
|
2
|
+
import os from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
import { projectDir } from "../tools.ts"
|
|
6
|
+
|
|
7
|
+
import YAML from "yaml"
|
|
8
|
+
|
|
9
|
+
import type { SkillsConfig } from "../config.ts"
|
|
10
|
+
import { copyState } from "../managed.ts"
|
|
11
|
+
|
|
12
|
+
/** Who reads a skill: ForgeQE's agent, Scribe (writing, healing, finding, reviewing tests), or the browser agent. */
|
|
13
|
+
export type SkillAgent = "forgeqe" | "scribe" | "browser"
|
|
14
|
+
export const SKILL_AGENTS: SkillAgent[] = ["forgeqe", "scribe", "browser"]
|
|
15
|
+
const DEFAULT_AGENTS: SkillAgent[] = ["forgeqe", "scribe"]
|
|
16
|
+
|
|
17
|
+
/** In precedence order, lowest first: a later source replaces an earlier skill of the same name. */
|
|
18
|
+
export type SkillSource = "prebuilt" | "external" | "user" | "project" | "extra"
|
|
19
|
+
|
|
20
|
+
export type Skill = {
|
|
21
|
+
name: string
|
|
22
|
+
description: string
|
|
23
|
+
agents: SkillAgent[]
|
|
24
|
+
source: SkillSource
|
|
25
|
+
/** The skill's folder (holding SKILL.md). */
|
|
26
|
+
dir: string
|
|
27
|
+
file: string
|
|
28
|
+
/** Sources whose skill of the same name this one replaced. */
|
|
29
|
+
overrides?: SkillSource[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type Registry = {
|
|
33
|
+
/** Enabled skills, by name. */
|
|
34
|
+
skills: Skill[]
|
|
35
|
+
/** Skills turned off with skills.disable. */
|
|
36
|
+
disabled: Skill[]
|
|
37
|
+
/** Folders or SKILL.md files that could not be used, with why. */
|
|
38
|
+
problems: string[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const PREBUILT_DIR = path.resolve(import.meta.dir, "..", "..", "skills")
|
|
42
|
+
const NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
|
43
|
+
const MAX_NAME = 64
|
|
44
|
+
const MAX_DESCRIPTION = 1024
|
|
45
|
+
const MAX_FILE = 100_000
|
|
46
|
+
|
|
47
|
+
/** The folders each source reads, in precedence order. `repo` adds its own .forgeqe/skills (and .claude/.agents with external). */
|
|
48
|
+
export function skillRoots(cfg: SkillsConfig, repo?: string): { source: SkillSource; dir: string }[] {
|
|
49
|
+
const roots: { source: SkillSource; dir: string }[] = [{ source: "prebuilt", dir: PREBUILT_DIR }]
|
|
50
|
+
if (cfg.external) {
|
|
51
|
+
const home = os.homedir()
|
|
52
|
+
for (const d of [path.join(home, ".claude", "skills"), path.join(home, ".agents", "skills")]) roots.push({ source: "external", dir: d })
|
|
53
|
+
if (repo) for (const d of [path.join(repo, ".claude", "skills"), path.join(repo, ".agents", "skills")]) roots.push({ source: "external", dir: d })
|
|
54
|
+
}
|
|
55
|
+
roots.push({ source: "user", dir: cfg.userDir })
|
|
56
|
+
roots.push({ source: "project", dir: cfg.projectDir })
|
|
57
|
+
const repoProject = repo ? path.join(projectDir(repo), "skills") : ""
|
|
58
|
+
if (repoProject && path.resolve(repoProject) !== path.resolve(cfg.projectDir)) roots.push({ source: "project", dir: repoProject })
|
|
59
|
+
for (const d of cfg.dirs) roots.push({ source: "extra", dir: d })
|
|
60
|
+
const seen = new Set<string>()
|
|
61
|
+
return roots.filter((r) => {
|
|
62
|
+
const key = path.resolve(r.dir)
|
|
63
|
+
if (seen.has(key)) return false
|
|
64
|
+
seen.add(key)
|
|
65
|
+
return true
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Frontmatter and body of a SKILL.md, or why it cannot be read. */
|
|
70
|
+
export function parseSkillFile(text: string): { meta: Record<string, unknown>; body: string } | { error: string } {
|
|
71
|
+
const src = text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n")
|
|
72
|
+
if (!src.startsWith("---\n")) return { error: "SKILL.md must start with YAML frontmatter between --- lines" }
|
|
73
|
+
const end = src.indexOf("\n---", 4)
|
|
74
|
+
if (end < 0) return { error: "the frontmatter has no closing --- line" }
|
|
75
|
+
let meta: unknown
|
|
76
|
+
try {
|
|
77
|
+
meta = YAML.parse(src.slice(4, end))
|
|
78
|
+
} catch (err) {
|
|
79
|
+
const why = (err as Error).message.split("\n")[0]
|
|
80
|
+
const hint = /mapping/i.test(why) ? '; a value containing ": " needs quotes, e.g. description: "Use when …: …"' : ""
|
|
81
|
+
return { error: `the frontmatter is not valid YAML (${why})${hint}` }
|
|
82
|
+
}
|
|
83
|
+
if (!meta || typeof meta !== "object" || Array.isArray(meta)) return { error: "the frontmatter must be a YAML mapping" }
|
|
84
|
+
const rest = src.slice(end + 4)
|
|
85
|
+
return { meta: meta as Record<string, unknown>, body: rest.replace(/^[^\n]*\n/, "").trim() }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readSkill(dir: string, source: SkillSource): Skill | { problem: string } {
|
|
89
|
+
const file = path.join(dir, "SKILL.md")
|
|
90
|
+
const folder = path.basename(dir)
|
|
91
|
+
const where = `${source} skill ${file}`
|
|
92
|
+
let text: string
|
|
93
|
+
try {
|
|
94
|
+
if (statSync(file).size > MAX_FILE) return { problem: `${where}: larger than ${MAX_FILE / 1000} KB` }
|
|
95
|
+
text = readFileSync(file, "utf8")
|
|
96
|
+
} catch (err) {
|
|
97
|
+
return { problem: `${where}: ${(err as Error).message}` }
|
|
98
|
+
}
|
|
99
|
+
const parsed = parseSkillFile(text)
|
|
100
|
+
if ("error" in parsed) return { problem: `${where}: ${parsed.error}` }
|
|
101
|
+
const { meta } = parsed
|
|
102
|
+
const name = typeof meta.name === "string" ? meta.name.trim() : ""
|
|
103
|
+
const description = typeof meta.description === "string" ? meta.description.replace(/\s+/g, " ").trim() : ""
|
|
104
|
+
if (!name) return { problem: `${where}: name is missing` }
|
|
105
|
+
if (!NAME.test(name) || name.length > MAX_NAME) return { problem: `${where}: name "${name}" must be lowercase words joined by hyphens, at most ${MAX_NAME} characters` }
|
|
106
|
+
if (name !== folder) return { problem: `${where}: name "${name}" must match its folder "${folder}"` }
|
|
107
|
+
if (!description) return { problem: `${where}: description is missing` }
|
|
108
|
+
if (description.length > MAX_DESCRIPTION) return { problem: `${where}: description is longer than ${MAX_DESCRIPTION} characters` }
|
|
109
|
+
let agents = DEFAULT_AGENTS
|
|
110
|
+
if (meta.agents !== undefined) {
|
|
111
|
+
// "webpilot" is ForgeQE's name from before the rename.
|
|
112
|
+
const raw = (Array.isArray(meta.agents) ? meta.agents.map((a) => String(a).trim()) : [String(meta.agents).trim()]).map((a) => (a === "webpilot" ? "forgeqe" : a))
|
|
113
|
+
const unknown = raw.filter((a) => !SKILL_AGENTS.includes(a as SkillAgent))
|
|
114
|
+
if (unknown.length || !raw.length) return { problem: `${where}: agents must list forgeqe, scribe, or browser; got ${raw.join(", ") || "nothing"}` }
|
|
115
|
+
agents = [...new Set(raw as SkillAgent[])]
|
|
116
|
+
}
|
|
117
|
+
return { name, description, agents, source, dir, file }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function skillDirs(root: string): string[] {
|
|
121
|
+
let entries: string[]
|
|
122
|
+
try {
|
|
123
|
+
entries = readdirSync(root)
|
|
124
|
+
} catch {
|
|
125
|
+
return []
|
|
126
|
+
}
|
|
127
|
+
return entries
|
|
128
|
+
.filter((e) => !e.startsWith("."))
|
|
129
|
+
.map((e) => path.join(root, e))
|
|
130
|
+
.filter((d) => {
|
|
131
|
+
try {
|
|
132
|
+
return statSync(d).isDirectory() && existsSync(path.join(d, "SKILL.md"))
|
|
133
|
+
} catch {
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
.sort()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Every skill from the configured sources: a later source replaces an earlier one by name, then skills.disable applies. */
|
|
141
|
+
export function loadSkills(cfg: SkillsConfig | undefined, repo?: string): Registry {
|
|
142
|
+
if (!cfg?.enabled) return { skills: [], disabled: [], problems: [] }
|
|
143
|
+
const byName = new Map<string, Skill>()
|
|
144
|
+
const problems: string[] = []
|
|
145
|
+
for (const root of skillRoots(cfg, repo)) {
|
|
146
|
+
for (const dir of skillDirs(root.dir)) {
|
|
147
|
+
// A copy forgeqe init made and nobody changed is still the built-in skill: the built-in (and its updates) wins.
|
|
148
|
+
if (root.source !== "prebuilt" && copyState(root.dir, path.basename(dir)) === "unchanged" && existsSync(path.join(PREBUILT_DIR, path.basename(dir), "SKILL.md"))) continue
|
|
149
|
+
const s = readSkill(dir, root.source)
|
|
150
|
+
if ("problem" in s) {
|
|
151
|
+
problems.push(s.problem)
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
const before = byName.get(s.name)
|
|
155
|
+
if (before) s.overrides = [...(before.overrides || []), before.source]
|
|
156
|
+
byName.set(s.name, s)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const off = new Set(cfg.disable)
|
|
160
|
+
const all = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name))
|
|
161
|
+
for (const name of off) if (!byName.has(name)) problems.push(`skills.disable names "${name}", which no source has`)
|
|
162
|
+
return { skills: all.filter((s) => !off.has(s.name)), disabled: all.filter((s) => off.has(s.name)), problems }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The enabled skills one agent may use. */
|
|
166
|
+
export function skillsFor(reg: Registry, agent: SkillAgent): Skill[] {
|
|
167
|
+
return reg.skills.filter((s) => s.agents.includes(agent))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function findSkill(reg: Registry, name: string): Skill | undefined {
|
|
171
|
+
return reg.skills.find((s) => s.name === name) || reg.disabled.find((s) => s.name === name)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** SKILL.md without its frontmatter. */
|
|
175
|
+
export function skillBody(skill: Skill): string {
|
|
176
|
+
const parsed = parseSkillFile(readFileSync(skill.file, "utf8"))
|
|
177
|
+
return "error" in parsed ? "" : parsed.body
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** "name: description" lines, for prompts. */
|
|
181
|
+
export function catalogLines(skills: Skill[]): string[] {
|
|
182
|
+
return skills.map((s) => `- ${s.name}: ${s.description}`)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A folder of links to the given skills, so the agent runtime sees exactly the registry's choice (one copy per name,
|
|
187
|
+
* whatever folder it came from). `remove` deletes the links, never the skills.
|
|
188
|
+
*/
|
|
189
|
+
export function linkSkills(skills: Skill[]): { dir: string; remove(): void } | null {
|
|
190
|
+
if (!skills.length) return null
|
|
191
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "forgeqe-skills-"))
|
|
192
|
+
for (const s of skills) {
|
|
193
|
+
try {
|
|
194
|
+
symlinkSync(s.dir, path.join(dir, s.name), process.platform === "win32" ? "junction" : "dir")
|
|
195
|
+
} catch {
|
|
196
|
+
// A skill that cannot be linked is left out rather than stopping the session.
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
dir,
|
|
201
|
+
remove() {
|
|
202
|
+
try {
|
|
203
|
+
for (const e of readdirSync(dir)) unlinkSync(path.join(dir, e))
|
|
204
|
+
rmdirSync(dir)
|
|
205
|
+
} catch {
|
|
206
|
+
// already gone
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Create the folder a new skill goes in; returns its SKILL.md path. */
|
|
213
|
+
export function ensureSkillDir(root: string, name: string): string {
|
|
214
|
+
const dir = path.join(root, name)
|
|
215
|
+
mkdirSync(dir, { recursive: true })
|
|
216
|
+
return path.join(dir, "SKILL.md")
|
|
217
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
import YAML from "yaml"
|
|
6
|
+
|
|
7
|
+
import { ensureSkillDir, SKILL_AGENTS, type SkillAgent } from "./registry.ts"
|
|
8
|
+
|
|
9
|
+
const NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
|
10
|
+
|
|
11
|
+
const BODY: Record<SkillAgent, string> = {
|
|
12
|
+
browser: `Write what the browser agent should know about this site or widget, as short imperative rules. For example:
|
|
13
|
+
|
|
14
|
+
- Lookup fields open a dropdown after typing; pick the row whose text matches exactly, then wait for the field to show it.
|
|
15
|
+
- The form saves with the Save button in the command bar; wait until "Saving..." disappears before the next step.`,
|
|
16
|
+
scribe: `Write the conventions a test in this repository must follow, as short imperative rules. For example:
|
|
17
|
+
|
|
18
|
+
- Tests live in tests/e2e and use the fixtures in tests/fixtures/auth.ts to sign in.
|
|
19
|
+
- Run one test with: npx playwright test <file> --project=chromium`,
|
|
20
|
+
forgeqe: `Write the steps ForgeQE's agent should follow for this kind of request, as short imperative rules. For example:
|
|
21
|
+
|
|
22
|
+
1. Run the smoke cases in checks/smoke.yaml with run_test_cases.
|
|
23
|
+
2. Post the summary to the release ticket named in the request.`,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type NewSkill = { name: string; description: string; agents: SkillAgent[]; body?: string }
|
|
27
|
+
|
|
28
|
+
/** Write `<root>/<name>/SKILL.md`; refuses an existing skill. Returns the file. */
|
|
29
|
+
export function newSkill(root: string, skill: NewSkill): string {
|
|
30
|
+
const name = skill.name.trim()
|
|
31
|
+
if (!NAME.test(name) || name.length > 64) throw new Error(`A skill name is lowercase words joined by hyphens (e.g. dynamics-date-picker); got "${name}".`)
|
|
32
|
+
const description = skill.description.replace(/\s+/g, " ").trim()
|
|
33
|
+
if (!description) throw new Error("A skill needs a description: when it applies, in one or two sentences. The model chooses skills by it.")
|
|
34
|
+
const agents = skill.agents.length ? skill.agents : (["forgeqe", "scribe"] as SkillAgent[])
|
|
35
|
+
const bad = agents.filter((a) => !SKILL_AGENTS.includes(a))
|
|
36
|
+
if (bad.length) throw new Error(`Unknown agent ${bad.join(", ")}; use forgeqe, scribe, or browser.`)
|
|
37
|
+
const file = ensureSkillDir(root, name)
|
|
38
|
+
if (existsSync(file)) throw new Error(`${file} already exists.`)
|
|
39
|
+
const front = YAML.stringify({ name, description, agents }, { lineWidth: 0 }).trim()
|
|
40
|
+
const body = skill.body?.trim() || BODY[agents.includes("browser") ? "browser" : agents.includes("scribe") ? "scribe" : "forgeqe"]
|
|
41
|
+
writeFileSync(file, `---\n${front}\n---\n\n# ${name}\n\n${body}\n`)
|
|
42
|
+
return file
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A note when git would not commit `file` (e.g. the repo ignores all of .forgeqe/), or null. */
|
|
46
|
+
export function ignoredNote(file: string): string | null {
|
|
47
|
+
const r = spawnSync("git", ["check-ignore", "-q", path.basename(file)], { cwd: path.dirname(file), stdio: "ignore" })
|
|
48
|
+
if (r.status !== 0) return null
|
|
49
|
+
return `git ignores ${file}, so the skill would not be shared. If .gitignore has .forgeqe/, replace it with .forgeqe/* and !.forgeqe/skills/.`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Add or remove `name` in skills.disable of forgeqe.yaml, keeping its comments. Returns whether the file changed. */
|
|
53
|
+
export function setSkillDisabled(configPath: string, name: string, disabled: boolean): boolean {
|
|
54
|
+
const doc = YAML.parseDocument(existsSync(configPath) ? readFileSync(configPath, "utf8") : "")
|
|
55
|
+
const raw = doc.getIn(["skills", "disable"])
|
|
56
|
+
const current = YAML.isSeq(raw) ? raw.items.map((i) => String(YAML.isScalar(i) ? i.value : i)) : []
|
|
57
|
+
const next = disabled ? [...new Set([...current, name])] : current.filter((n) => n !== name)
|
|
58
|
+
if (next.length === current.length && next.every((n, i) => n === current[i])) return false
|
|
59
|
+
if (doc.get("skills") === false) throw new Error(`Skills are turned off in ${configPath} (skills: false).`)
|
|
60
|
+
if (!YAML.isMap(doc.get("skills"))) doc.set("skills", doc.createNode({}))
|
|
61
|
+
if (next.length) doc.setIn(["skills", "disable"], doc.createNode(next, { flow: true }))
|
|
62
|
+
else doc.deleteIn(["skills", "disable"])
|
|
63
|
+
writeFileSync(configPath, doc.toString())
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { SkillsConfig } from "../config.ts"
|
|
2
|
+
import type { EngineRequest } from "../engine/bridge.ts"
|
|
3
|
+
import { loadSkills, skillBody, skillsFor } from "./registry.ts"
|
|
4
|
+
|
|
5
|
+
/** Skills the engine chose for a goal, so later runs of it (monitor rounds, a suite's retries) skip the choice. */
|
|
6
|
+
const chosenByGoal = new Map<string, string[]>()
|
|
7
|
+
|
|
8
|
+
export type BrowserSkills = Pick<EngineRequest, "skills" | "skillsPick" | "skillsMaxChars"> & { unknown: string[] }
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What the engine gets for a run's browser skills. `names` are the skills the caller picked (ForgeQE's agent or
|
|
12
|
+
* --skill; empty means none); without them the engine chooses from every browser skill, once per goal.
|
|
13
|
+
*/
|
|
14
|
+
export function browserSkills(cfg: SkillsConfig | undefined, goal: string, names?: string[], repo?: string): BrowserSkills {
|
|
15
|
+
const none: BrowserSkills = { unknown: [] }
|
|
16
|
+
const catalog = skillsFor(loadSkills(cfg, repo), "browser")
|
|
17
|
+
if (!cfg || !catalog.length) return { unknown: names || [] }
|
|
18
|
+
const remembered = names ? undefined : chosenByGoal.get(goal)
|
|
19
|
+
const given = names || remembered
|
|
20
|
+
const entry = (s: (typeof catalog)[number]) => ({ name: s.name, description: s.description, body: skillBody(s) })
|
|
21
|
+
if (given) {
|
|
22
|
+
const picked = given.flatMap((n) => catalog.filter((s) => s.name === n))
|
|
23
|
+
const unknown = given.filter((n) => !catalog.some((s) => s.name === n))
|
|
24
|
+
if (!picked.length) return { ...none, unknown }
|
|
25
|
+
return { skills: picked.map(entry), skillsPick: "given", skillsMaxChars: cfg.browserMaxChars, unknown }
|
|
26
|
+
}
|
|
27
|
+
return { skills: catalog.map(entry), skillsPick: "choose", skillsMaxChars: cfg.browserMaxChars, unknown: [] }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** After a run where the engine chose (`used` is set, empty when nothing fit): remember the choice for the goal. */
|
|
31
|
+
export function rememberChoice(goal: string, pick: BrowserSkills["skillsPick"], used: string[] | undefined): void {
|
|
32
|
+
if (pick === "choose" && used) chosenByGoal.set(goal, used)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function forgetChoices(): void {
|
|
36
|
+
chosenByGoal.clear()
|
|
37
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
|
|
3
|
+
import type { SkillsConfig } from "../config.ts"
|
|
4
|
+
import { skillBody, skillRoots, skillsFor, type Registry, type Skill, type SkillAgent } from "./registry.ts"
|
|
5
|
+
|
|
6
|
+
const AGENT_LABEL: Record<SkillAgent, string> = { forgeqe: "ForgeQE's agent", scribe: "Scribe (tests)", browser: "Browser agent" }
|
|
7
|
+
|
|
8
|
+
const short = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
|
|
9
|
+
|
|
10
|
+
function origin(s: Skill): string {
|
|
11
|
+
return s.overrides?.length ? `${s.source}, replaces ${s.overrides.join(", ")}` : s.source
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The skills each agent can use, the disabled ones, problems, and the folders read. */
|
|
15
|
+
export function skillsOverview(reg: Registry, cfg: SkillsConfig | undefined, repo?: string): string {
|
|
16
|
+
if (!cfg?.enabled) return "Skills are turned off (skills: false or skills.enabled: false in forgeqe.yaml)."
|
|
17
|
+
const width = Math.max(12, ...[...reg.skills, ...reg.disabled].map((s) => s.name.length)) + 2
|
|
18
|
+
const row = (s: Skill) => ` ${s.name.padEnd(width)}${`[${origin(s)}]`.padEnd(30)} ${short(s.description, 90)}`
|
|
19
|
+
const out: string[] = []
|
|
20
|
+
for (const agent of ["forgeqe", "scribe", "browser"] as SkillAgent[]) {
|
|
21
|
+
const list = skillsFor(reg, agent)
|
|
22
|
+
out.push(`${AGENT_LABEL[agent]} (${list.length})`)
|
|
23
|
+
out.push(...(list.length ? list.map(row) : [" none"]))
|
|
24
|
+
}
|
|
25
|
+
if (reg.disabled.length) out.push("", "Disabled (skills.disable)", ...reg.disabled.map(row))
|
|
26
|
+
if (reg.problems.length) out.push("", "Problems", ...reg.problems.map((p) => ` ✗ ${p}`))
|
|
27
|
+
const home = process.env.HOME || ""
|
|
28
|
+
const tidy = (d: string) => (home && d.startsWith(home) ? `~${d.slice(home.length)}` : path.relative(process.cwd(), d) || ".")
|
|
29
|
+
out.push("", "Folders read (later ones replace a skill of the same name)")
|
|
30
|
+
for (const r of skillRoots(cfg, repo)) out.push(` ${r.source.padEnd(9)}${tidy(r.dir)}`)
|
|
31
|
+
return out.join("\n")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One skill: where it comes from, who uses it, and its instructions. */
|
|
35
|
+
export function skillDetail(s: Skill, disabled = false): string {
|
|
36
|
+
return [
|
|
37
|
+
`${s.name}${disabled ? " (disabled)" : ""}`,
|
|
38
|
+
` ${s.description}`,
|
|
39
|
+
` used by: ${s.agents.map((a) => AGENT_LABEL[a]).join(", ")}`,
|
|
40
|
+
` from: ${origin(s)} · ${s.file}`,
|
|
41
|
+
"",
|
|
42
|
+
skillBody(s),
|
|
43
|
+
].join("\n")
|
|
44
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import { stepVerdictLines, type CaseResult } from "../cases/types.ts"
|
|
5
|
+
import { renderReport } from "../report/html.ts"
|
|
6
|
+
import type { Usage } from "../types.ts"
|
|
7
|
+
import { addCost, addUsage, usageLine, type Cost } from "../usage.ts"
|
|
8
|
+
import type { SuiteResult } from "./run.ts"
|
|
9
|
+
|
|
10
|
+
const xmlEsc = (s: string) =>
|
|
11
|
+
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "")
|
|
12
|
+
|
|
13
|
+
export function counts(results: CaseResult[]) {
|
|
14
|
+
return {
|
|
15
|
+
total: results.length,
|
|
16
|
+
passed: results.filter((r) => r.status === "passed").length,
|
|
17
|
+
failed: results.filter((r) => r.status === "failed").length,
|
|
18
|
+
errors: results.filter((r) => r.status === "error").length,
|
|
19
|
+
stopped: results.filter((r) => r.status === "stopped").length,
|
|
20
|
+
coded: results.filter((r) => r.code).length,
|
|
21
|
+
codePassed: results.filter((r) => r.code?.status === "passed").length,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function writeReports(s: Omit<SuiteResult, "files">): SuiteResult["files"] {
|
|
26
|
+
const files = { junit: path.join(s.dir, "junit.xml"), html: path.join(s.dir, "report.html"), json: path.join(s.dir, "suite.json") }
|
|
27
|
+
writeFileSync(files.junit, junit(s))
|
|
28
|
+
writeFileSync(files.html, renderReport({ title: s.name, startedAt: s.startedAt, ms: s.ms, stopped: s.stopped, dir: s.dir, results: s.results, posted: s.writeBack, env: s.env }))
|
|
29
|
+
writeFileSync(files.json, `${JSON.stringify({ ...s, files }, null, 2)}\n`)
|
|
30
|
+
return files
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function junit(s: Omit<SuiteResult, "files">): string {
|
|
34
|
+
const c = counts(s.results)
|
|
35
|
+
const secs = (ms: number) => (ms / 1000).toFixed(3)
|
|
36
|
+
const cases = s.results.map((r) => {
|
|
37
|
+
const out = [r.summary, ...stepVerdictLines(r), r.runDir ? `Run: ${r.runDir}` : "", r.case.url ? `Case: ${r.case.url}` : ""]
|
|
38
|
+
for (const w of s.writeBack.filter((w) => w.caseId === r.case.id && w.url)) out.push(`${w.text}: ${w.url}`)
|
|
39
|
+
if (r.code) out.push(`Test ${r.code.status}: ${r.code.testFile || "(none)"}${r.code.command ? ` (${r.code.command})` : ""}`)
|
|
40
|
+
else if (r.codeSkipped) out.push(`No test: ${r.codeSkipped}`)
|
|
41
|
+
const body =
|
|
42
|
+
r.status === "failed"
|
|
43
|
+
? `<failure message="${xmlEsc(firstLine(r.summary) || "failed")}">${xmlEsc(r.summary)}</failure>`
|
|
44
|
+
: r.status === "error"
|
|
45
|
+
? `<error message="${xmlEsc(firstLine(r.summary) || "error")}">${xmlEsc(r.summary)}</error>`
|
|
46
|
+
: r.status === "stopped"
|
|
47
|
+
? `<skipped message="stopped"/>`
|
|
48
|
+
: ""
|
|
49
|
+
return ` <testcase classname="forgeqe.${r.case.source}" name="${xmlEsc(`${r.case.id} ${r.case.title}`)}" time="${secs(r.ms)}">${body}<system-out>${xmlEsc(out.filter(Boolean).join("\n"))}</system-out></testcase>`
|
|
50
|
+
})
|
|
51
|
+
return [
|
|
52
|
+
`<?xml version="1.0" encoding="UTF-8"?>`,
|
|
53
|
+
`<testsuites name="${xmlEsc(s.name)}" tests="${c.total}" failures="${c.failed}" errors="${c.errors}" skipped="${c.stopped}" time="${secs(s.ms)}">`,
|
|
54
|
+
` <testsuite name="ForgeQE" tests="${c.total}" failures="${c.failed}" errors="${c.errors}" skipped="${c.stopped}" time="${secs(s.ms)}" timestamp="${s.startedAt}">`,
|
|
55
|
+
...cases,
|
|
56
|
+
` </testsuite>`,
|
|
57
|
+
`</testsuites>`,
|
|
58
|
+
"",
|
|
59
|
+
].join("\n")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Every case's browser and Scribe tokens together, and their cost (unset when any part has no price). */
|
|
63
|
+
export function suiteUsage(results: CaseResult[]): { usage: Usage; cost?: Cost } {
|
|
64
|
+
let usage: Usage = { input: 0, output: 0 }
|
|
65
|
+
let cost: Cost | undefined
|
|
66
|
+
let priced = true
|
|
67
|
+
for (const r of results) {
|
|
68
|
+
for (const [u, c] of [[r.usage, r.cost], [r.codeUsage, r.codeCost]] as const) {
|
|
69
|
+
if (!u || (!u.input && !u.output)) continue
|
|
70
|
+
usage = addUsage(usage, u)
|
|
71
|
+
if (c) cost = addCost(cost, c)
|
|
72
|
+
else priced = false
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { usage, cost: priced ? cost : undefined }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The end-of-suite summary for the terminal. */
|
|
79
|
+
export function formatSuite(s: SuiteResult, cwd = process.cwd()): string {
|
|
80
|
+
const c = counts(s.results)
|
|
81
|
+
const tokens = s.results.reduce((n, r) => n + r.tokens, 0)
|
|
82
|
+
const head = `${s.stopped ? "■ Suite stopped" : c.passed === c.total ? "✓ Suite passed" : "✗ Suite failed"} · ${c.passed}/${c.total} passed · ${duration(s.ms)} · ${(tokens / 1000).toFixed(1)}K tokens`
|
|
83
|
+
const total = suiteUsage(s.results)
|
|
84
|
+
const lines = [head, ""]
|
|
85
|
+
if (total.usage.input || total.usage.output) lines.push(`Usage ${usageLine(total.usage, total.cost)}`, "")
|
|
86
|
+
for (const r of s.results) {
|
|
87
|
+
const code = r.code ? ` test ${r.code.status}${r.code.testFile ? `: ${r.code.testFile}` : ""}` : ""
|
|
88
|
+
lines.push(`${MARK[r.status]} ${r.case.id} ${r.case.title} (${duration(r.ms)})${code}`)
|
|
89
|
+
if (r.status !== "passed" && r.summary) lines.push(` ${firstLine(r.summary)}`)
|
|
90
|
+
const failedStep = r.stepResults?.find((v) => v.status === "failed")
|
|
91
|
+
if (r.status !== "passed" && failedStep) lines.push(` step ${failedStep.step} failed${failedStep.actual ? `: ${firstLine(failedStep.actual)}` : ""}`)
|
|
92
|
+
}
|
|
93
|
+
if (s.writeBack.length) {
|
|
94
|
+
lines.push("")
|
|
95
|
+
for (const w of s.writeBack) lines.push(`${w.error ? "✗" : "↑"} ${w.text}${w.url ? ` ${w.url}` : ""}${w.error ? `: ${w.error}` : ""}`)
|
|
96
|
+
}
|
|
97
|
+
const show = (f: string) => path.relative(cwd, f) || f
|
|
98
|
+
lines.push("", `Report ${show(s.files.html)}`, `JUnit ${show(s.files.junit)}`)
|
|
99
|
+
return lines.join("\n")
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** GitHub Actions job summary (GITHUB_STEP_SUMMARY): the result line and one table row per case. */
|
|
103
|
+
export function markdownSummary(s: SuiteResult): string {
|
|
104
|
+
const c = counts(s.results)
|
|
105
|
+
const cell = (t: string) => t.replace(/\|/g, "\\|").replace(/\s+/g, " ").trim()
|
|
106
|
+
const head = s.stopped ? "■ Suite stopped" : c.passed === c.total ? "✓ Suite passed" : "✗ Suite failed"
|
|
107
|
+
const rows = s.results.map((r) => {
|
|
108
|
+
const test = r.code ? `${r.code.status}${r.code.testFile ? ` \`${cell(r.code.testFile)}\`` : ""}` : ""
|
|
109
|
+
const steps = r.stepResults?.length && r.case.steps.length ? `${r.stepResults.filter((v) => v.status === "passed").length}/${r.case.steps.length}` : ""
|
|
110
|
+
const bug = s.writeBack.find((w) => w.caseId === r.case.id && w.source === "bugs" && w.url)
|
|
111
|
+
const result = cell(r.status === "passed" ? "" : firstLine(r.summary)) + (bug ? ` [${cell(bug.text.split(":")[0])}](${bug.url})` : "")
|
|
112
|
+
return `| ${MARK[r.status]} | ${cell(r.case.id)} | ${cell(r.case.title)} | ${steps} | ${duration(r.ms)} | ${result} | ${test} |`
|
|
113
|
+
})
|
|
114
|
+
const posted = s.writeBack.filter((w) => !w.caseId || w.error).map((w) => `- ${w.error ? "✗" : "↑"} ${w.text}${w.url ? ` ${w.url}` : ""}${w.error ? `: ${w.error}` : ""}`)
|
|
115
|
+
return [
|
|
116
|
+
`### ${head} · ${c.passed}/${c.total} passed · ${duration(s.ms)}`,
|
|
117
|
+
"",
|
|
118
|
+
"| | Case | Title | Steps | Time | Result | Test |",
|
|
119
|
+
"|---|---|---|---|---|---|---|",
|
|
120
|
+
...rows,
|
|
121
|
+
...(posted.length ? ["", ...posted] : []),
|
|
122
|
+
"",
|
|
123
|
+
].join("\n")
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function firstLine(s: string): string {
|
|
127
|
+
return s.split("\n")[0].slice(0, 200)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const MARK = { passed: "✓", failed: "✗", error: "!", stopped: "■" } as const
|
|
131
|
+
|
|
132
|
+
function duration(ms: number): string {
|
|
133
|
+
const s = Math.round(ms / 1000)
|
|
134
|
+
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`
|
|
135
|
+
}
|