@capagents/forgeqe 1.1.0 → 1.1.2

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 (64) hide show
  1. package/HOW_TO_USE.md +30 -13
  2. package/README.md +27 -1
  3. package/docs/user-guide.html +33 -13
  4. package/evals/prompts/brain/capability-question.yaml +12 -0
  5. package/evals/prompts/brain/failed-runs-cause.yaml +12 -0
  6. package/evals/prompts/brain/release-route.yaml +14 -0
  7. package/evals/prompts/brain/report-request.yaml +13 -0
  8. package/evals/prompts/brain/secret-request.yaml +9 -0
  9. package/evals/prompts/explore-charters/lockout.yaml +18 -0
  10. package/evals/prompts/explore-charters/transfer-limit.yaml +16 -0
  11. package/evals/prompts/refine/current-release.yaml +26 -0
  12. package/evals/prompts/refine/missing-item.yaml +10 -0
  13. package/evals/prompts/refine/question-stays-question.yaml +13 -0
  14. package/evals/prompts/refine/report-request.yaml +19 -0
  15. package/forgeqe.example.yaml +39 -0
  16. package/package.json +1 -1
  17. package/prompts/README.md +28 -7
  18. package/prompts/api-scenarios.md +56 -24
  19. package/prompts/api-tests.md +67 -51
  20. package/prompts/brain.md +283 -266
  21. package/prompts/browser-capture.md +8 -7
  22. package/prompts/browser-case-report.md +13 -12
  23. package/prompts/browser-checks.md +17 -18
  24. package/prompts/browser-efficiency.md +4 -1
  25. package/prompts/browser-error-recovery.md +5 -4
  26. package/prompts/browser-extra.md +1 -0
  27. package/prompts/browser-goal-verification.md +8 -7
  28. package/prompts/browser-skill-select.md +28 -1
  29. package/prompts/conventions.md +10 -0
  30. package/prompts/domain-rule.md +17 -14
  31. package/prompts/eval-judge.md +36 -13
  32. package/prompts/explore-charters.md +53 -22
  33. package/prompts/find-tests.md +45 -30
  34. package/prompts/heal.md +42 -32
  35. package/prompts/pr-review.md +44 -28
  36. package/prompts/refine.md +73 -36
  37. package/prompts/skill-rule.md +4 -1
  38. package/prompts/test-agent.md +61 -45
  39. package/prompts/test-reviewer.md +64 -37
  40. package/prompts/test-rules.md +59 -56
  41. package/py/forgeqe_engine/browser.py +111 -1
  42. package/py/forgeqe_engine/capture.py +8 -1
  43. package/py/forgeqe_engine/checks.py +20 -8
  44. package/py/forgeqe_engine/prompts.py +9 -0
  45. package/py/forgeqe_engine/report.py +15 -8
  46. package/py/forgeqe_engine/service.py +50 -28
  47. package/py/forgeqe_engine/skills.py +31 -7
  48. package/src/auth/signin.ts +30 -1
  49. package/src/bi.ts +899 -0
  50. package/src/childEnv.ts +4 -0
  51. package/src/cli/help.ts +10 -2
  52. package/src/cli/index.ts +114 -10
  53. package/src/config.ts +149 -0
  54. package/src/engine/bridge.ts +32 -2
  55. package/src/evals/prompts.ts +288 -0
  56. package/src/mcp/server.ts +19 -1
  57. package/src/mcp/tools.ts +27 -0
  58. package/src/prompts.ts +6 -3
  59. package/src/promptstyle.ts +159 -0
  60. package/src/refine.ts +30 -14
  61. package/src/run.ts +1 -0
  62. package/src/ste.ts +8 -2
  63. package/src/tui/App.tsx +16 -2
  64. package/src/tui/refine-panel.tsx +9 -5
@@ -0,0 +1,288 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import YAML from "yaml"
5
+
6
+ import type { AppConfig } from "../config.ts"
7
+ import { configuredSystems } from "../capabilities/status.ts"
8
+ import { steScore } from "../ste.ts"
9
+ import type { CodeEvent } from "../types.ts"
10
+
11
+ /**
12
+ * Evaluation-driven prompting: each prompt that can run on its own has cases (evals/prompts/<prompt>/*.yaml in
13
+ * ForgeQE, and .forgeqe/evals/prompts/<prompt>/ for a project's own), each an input and checks that are decided by
14
+ * code, not by a model: a field matches or is empty, the agent used or did not use a tool, the text keeps ASD-STE100.
15
+ * `forgeqe prompts eval` runs them, several times when asked, and compares the pass rate with the run before, so a
16
+ * change to a prompt is measured, not guessed.
17
+ */
18
+
19
+ export const BUILTIN_CASES = path.resolve(import.meta.dir, "..", "..", "evals", "prompts")
20
+
21
+ /** One check. Exactly one of the test keys is set besides `id` (and `field` for the field tests). */
22
+ export type Check = {
23
+ id: string
24
+ field?: string
25
+ matches?: string
26
+ not_matches?: string
27
+ empty?: boolean
28
+ min_items?: number
29
+ max_items?: number
30
+ /** Least share of sentences that keep ASD-STE100, of `field` (default: the output text). */
31
+ ste_min?: number
32
+ /** At least one tool call matches one of these names (`*` matches any text). */
33
+ tools_any?: string[]
34
+ /** No tool call matches any of these. */
35
+ tools_none?: string[]
36
+ /** The first call that matches the first list comes before any call that matches the second. */
37
+ tools_before?: [string[], string[]]
38
+ }
39
+
40
+ export type PromptCase = {
41
+ id: string
42
+ title: string
43
+ prompt: string
44
+ input: string
45
+ /** Systems the case reads: it is skipped when one is not set up. */
46
+ needs: string[]
47
+ /** brain: the ForgeQE tools that may run; others answer that they are not available (default: those that only read). */
48
+ allow?: string[]
49
+ checks: Check[]
50
+ file: string
51
+ }
52
+
53
+ export type Observation = { fields: Record<string, unknown>; text: string; tools: string[]; tokens: number; ms: number }
54
+ export type CheckResult = { id: string; pass: boolean; detail: string }
55
+ export type RunResult = { pass: boolean; checks: CheckResult[]; tokens: number; ms: number; error?: string; /** The start of what came out, for a run that failed. */ seen?: string }
56
+ export type CaseResult = { prompt: string; case: string; title: string; runs: RunResult[]; skipped?: string }
57
+
58
+ /** The ForgeQE tools ForgeQE's agent may call in an evaluation: each only reads. */
59
+ export const READ_ONLY_TOOLS = ["capabilities", "find_test_cases", "check_test_cases", "read_confluence", "run_details", "query_api", "list_sprints", "page_memory", "flaky_tests", "quality_report", "quality_trends", "forgeqe_setup"]
60
+
61
+ const TESTS = ["matches", "not_matches", "empty", "min_items", "max_items", "ste_min", "tools_any", "tools_none", "tools_before"] as const
62
+
63
+ /** Every case in a folder of `<prompt>/<case>.yaml`, checked so a broken case fails before any model is called. */
64
+ export function loadPromptCases(dirs: string[], only?: { prompt?: string; case?: string }): PromptCase[] {
65
+ const out: PromptCase[] = []
66
+ for (const dir of dirs) {
67
+ if (!existsSync(dir)) continue
68
+ for (const prompt of readdirSync(dir).sort()) {
69
+ if (only?.prompt && prompt !== only.prompt) continue
70
+ const folder = path.join(dir, prompt)
71
+ let files: string[] = []
72
+ try {
73
+ files = readdirSync(folder).filter((f) => /\.ya?ml$/.test(f)).sort()
74
+ } catch {
75
+ continue
76
+ }
77
+ for (const f of files) {
78
+ const file = path.join(folder, f)
79
+ const c = parseCase(YAML.parse(readFileSync(file, "utf8")), prompt, file)
80
+ if (only?.case && c.id !== only.case) continue
81
+ // A project's case replaces ForgeQE's case of the same id.
82
+ const at = out.findIndex((x) => x.prompt === c.prompt && x.id === c.id)
83
+ if (at >= 0) out.splice(at, 1)
84
+ out.push(c)
85
+ }
86
+ }
87
+ }
88
+ return out
89
+ }
90
+
91
+ export function parseCase(raw: any, prompt: string, file: string): PromptCase {
92
+ const where = `${path.basename(path.dirname(file))}/${path.basename(file)}`
93
+ if (!raw?.id || !raw.input) throw new Error(`${where}: id and input are required.`)
94
+ if (!Array.isArray(raw.checks) || !raw.checks.length) throw new Error(`${where}: checks needs at least one check.`)
95
+ const checks: Check[] = raw.checks.map((c: any, i: number) => {
96
+ if (!c?.id) throw new Error(`${where}: check ${i + 1} needs an id.`)
97
+ const tests = TESTS.filter((t) => c[t] !== undefined)
98
+ if (tests.length !== 1) throw new Error(`${where}: check ${c.id} needs exactly one of ${TESTS.join(", ")}.`)
99
+ if (["matches", "not_matches", "empty", "min_items", "max_items"].includes(tests[0]!) && !c.field) throw new Error(`${where}: check ${c.id} needs field.`)
100
+ for (const k of ["matches", "not_matches"]) if (c[k] !== undefined) new RegExp(String(c[k]), "i")
101
+ return c as Check
102
+ })
103
+ const ids = checks.map((c) => c.id)
104
+ if (new Set(ids).size !== ids.length) throw new Error(`${where}: check ids must be unique.`)
105
+ return {
106
+ id: String(raw.id),
107
+ title: String(raw.title || raw.id),
108
+ prompt: String(raw.prompt || prompt),
109
+ input: String(raw.input),
110
+ needs: (raw.needs || []).map(String),
111
+ ...(raw.allow ? { allow: raw.allow.map(String) } : {}),
112
+ checks,
113
+ file,
114
+ }
115
+ }
116
+
117
+ const glob = (pattern: string) => new RegExp(`^${pattern.split("*").map((p) => p.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`, "i")
118
+ const matchesAny = (tool: string, patterns: string[]) => patterns.some((p) => glob(p).test(tool))
119
+ const asText = (v: unknown): string => (v === undefined || v === null ? "" : typeof v === "string" ? v : Array.isArray(v) ? v.map(asText).join("\n") : JSON.stringify(v))
120
+
121
+ /** Each check decided against what one run did. */
122
+ export function judge(checks: Check[], o: Observation): CheckResult[] {
123
+ return checks.map((c): CheckResult => {
124
+ const value = c.field ? o.fields[c.field] : o.text
125
+ const text = asText(value)
126
+ const items = Array.isArray(value) ? value.length : text.trim() ? 1 : 0
127
+ if (c.matches !== undefined) {
128
+ const pass = new RegExp(c.matches, "i").test(text)
129
+ return { id: c.id, pass, detail: pass ? `${c.field} matches /${c.matches}/` : `${c.field} does not match /${c.matches}/: ${clip(text)}` }
130
+ }
131
+ if (c.not_matches !== undefined) {
132
+ const m = new RegExp(c.not_matches, "i").exec(text)
133
+ return { id: c.id, pass: !m, detail: m ? `${c.field} has "${m[0]}": ${clip(text)}` : `${c.field} does not match /${c.not_matches}/` }
134
+ }
135
+ if (c.empty !== undefined) {
136
+ const pass = (items === 0) === c.empty
137
+ return { id: c.id, pass, detail: pass ? `${c.field} is ${c.empty ? "empty" : "not empty"}` : `${c.field} is ${c.empty ? `not empty: ${clip(text)}` : "empty"}` }
138
+ }
139
+ if (c.min_items !== undefined) return { id: c.id, pass: items >= c.min_items, detail: `${c.field} has ${items} (at least ${c.min_items})` }
140
+ if (c.max_items !== undefined) return { id: c.id, pass: items <= c.max_items, detail: `${c.field} has ${items} (at most ${c.max_items})` }
141
+ if (c.ste_min !== undefined) {
142
+ const score = steScore(text).score
143
+ return { id: c.id, pass: score >= c.ste_min, detail: `ASD-STE100 ${Math.round(score * 100)}% (at least ${Math.round(c.ste_min * 100)}%)` }
144
+ }
145
+ if (c.tools_any) {
146
+ const hit = o.tools.find((t) => matchesAny(t, c.tools_any!))
147
+ return { id: c.id, pass: Boolean(hit), detail: hit ? `used ${hit}` : `used none of ${c.tools_any.join(", ")} (used: ${clip(o.tools.join(", ") || "no tools")})` }
148
+ }
149
+ if (c.tools_none) {
150
+ const hit = o.tools.filter((t) => matchesAny(t, c.tools_none!))
151
+ return { id: c.id, pass: !hit.length, detail: hit.length ? `used ${[...new Set(hit)].join(", ")}` : `used none of ${c.tools_none.join(", ")}` }
152
+ }
153
+ const [first, then] = c.tools_before!
154
+ const a = o.tools.findIndex((t) => matchesAny(t, first))
155
+ const b = o.tools.findIndex((t) => matchesAny(t, then))
156
+ const pass = a >= 0 && (b < 0 || a < b)
157
+ return { id: c.id, pass, detail: a < 0 ? `used none of ${first.join(", ")}` : b >= 0 && b < a ? `used ${o.tools[b]} before ${o.tools[a]}` : `used ${o.tools[a]} first` }
158
+ })
159
+ }
160
+
161
+ const clip = (s: string, n = 120) => {
162
+ const t = s.replace(/\s+/g, " ").trim()
163
+ return t.length <= n ? t : `${t.slice(0, n - 1)}…`
164
+ }
165
+
166
+ /** Why a case cannot run with this config, or null. */
167
+ export function skipReason(c: PromptCase, config: AppConfig): string | null {
168
+ if (config.llm.provider === "none" || config.llm.provider === "mock") return "no live model"
169
+ const systems = configuredSystems(config)
170
+ const missing = c.needs.filter((n) => !systems.has(n as never))
171
+ return missing.length ? `needs ${missing.join(", ")}` : null
172
+ }
173
+
174
+ export type Runner = (c: PromptCase, config: AppConfig, cwd: string) => Promise<Observation>
175
+
176
+ /** Tokens a session used, from its last usage event (the runtime reports running totals). */
177
+ function usageMeter() {
178
+ let tokens = 0
179
+ return {
180
+ onEvent: (e: CodeEvent) => {
181
+ if (e.kind === "usage") tokens = e.usage.input + e.usage.output
182
+ },
183
+ get tokens() {
184
+ return tokens
185
+ },
186
+ }
187
+ }
188
+
189
+ /** How each prompt that can run on its own is run for a case. */
190
+ export const RUNNERS: Record<string, Runner> = {
191
+ async refine(c, config, cwd) {
192
+ const { refine } = await import("../refine.ts")
193
+ const meter = usageMeter()
194
+ const started = Date.now()
195
+ const r = await refine({ config, cwd, text: c.input, recent: [], onEvent: meter.onEvent })
196
+ const fields: Record<string, unknown> = { ...(r.spec || {}), refined: r.refined, missing: r.missing, assumed: r.assumed, grounded: r.grounded.map((g) => g.fact) }
197
+ return { fields, text: r.refined, tools: [], tokens: meter.tokens, ms: Date.now() - started }
198
+ },
199
+
200
+ async brain(c, config, cwd) {
201
+ const { openBrain } = await import("../brain/index.ts")
202
+ const tools: string[] = []
203
+ let tokens = 0
204
+ const allow = new Set(c.allow || READ_ONLY_TOOLS)
205
+ const started = Date.now()
206
+ const brain = await openBrain({
207
+ config,
208
+ cwd,
209
+ context: () => ({ session: "shell" as const, observe: (tool: string) => tools.push(`forgeqe_${tool}`), allow: (tool: string) => allow.has(tool) }),
210
+ onEvent: (e) => {
211
+ if (e.kind === "usage") tokens = e.usage.input + e.usage.output
212
+ // ForgeQE's own tools are hidden from these events; observe records them.
213
+ if (e.kind === "tool" && e.tool && !e.tool.startsWith("forgeqe_")) tools.push(e.tool)
214
+ },
215
+ })
216
+ try {
217
+ const a = await brain.ask(c.input)
218
+ return { fields: { reply: a.reply, outcome: a.outcome, next: (a.next || []).map((n) => n.label) }, text: a.reply, tools, tokens, ms: Date.now() - started }
219
+ } finally {
220
+ await brain.close()
221
+ }
222
+ },
223
+
224
+ async "explore-charters"(c, config, cwd) {
225
+ const { planCharters } = await import("../explore/charters.ts")
226
+ const meter = usageMeter()
227
+ const started = Date.now()
228
+ const [title, ...rest] = c.input.split("\n")
229
+ const acceptance = rest.join("\n")
230
+ const story = { id: "story", system: "text" as const, title: title!.trim(), description: "", acceptance, parents: [], scripted: [], passages: [], errors: [] }
231
+ const r = await planCharters({ config, story, count: 5, roles: [], cwd, onEvent: meter.onEvent })
232
+ const fields = { charters: r.charters, titles: r.charters.map((x) => x.title), categories: r.charters.map((x) => x.category), steps: r.charters.flatMap((x) => x.steps.map((s) => `${s.action} → ${s.expected}`)) }
233
+ // Each mission is a paragraph of its own, ended as a sentence, so writing checks score each one.
234
+ const text = r.charters.map((x) => (/[.!?]$/.test(x.mission.trim()) ? x.mission.trim() : `${x.mission.trim()}.`)).join("\n\n")
235
+ return { fields, text, tools: [], tokens: meter.tokens, ms: Date.now() - started }
236
+ },
237
+ }
238
+
239
+ export async function runCase(c: PromptCase, config: AppConfig, cwd: string, runs: number): Promise<CaseResult> {
240
+ const base = { prompt: c.prompt, case: c.id, title: c.title }
241
+ const why = skipReason(c, config)
242
+ if (why) return { ...base, runs: [], skipped: why }
243
+ const runner = RUNNERS[c.prompt]
244
+ if (!runner) return { ...base, runs: [], skipped: `no runner for ${c.prompt} yet` }
245
+ const out: RunResult[] = []
246
+ for (let i = 0; i < runs; i++) {
247
+ const started = Date.now()
248
+ try {
249
+ const o = await runner(c, config, cwd)
250
+ const checks = judge(c.checks, o)
251
+ const pass = checks.every((x) => x.pass)
252
+ out.push({ pass, checks, tokens: o.tokens, ms: o.ms, ...(pass ? {} : { seen: clip(o.text, 300) }) })
253
+ } catch (err) {
254
+ out.push({ pass: false, checks: [], tokens: 0, ms: Date.now() - started, error: (err as Error).message })
255
+ }
256
+ }
257
+ return { ...base, runs: out }
258
+ }
259
+
260
+ /** Where results are kept, one file per `forgeqe prompts eval`. */
261
+ export const resultsDir = (config: AppConfig) => path.join(config.export.outDir, "evals", "prompts")
262
+
263
+ export function saveResults(config: AppConfig, results: CaseResult[], at = new Date()): string {
264
+ const dir = resultsDir(config)
265
+ mkdirSync(dir, { recursive: true })
266
+ const file = path.join(dir, `${at.toISOString().replace(/[:.]/g, "-")}.json`)
267
+ writeFileSync(file, JSON.stringify({ at: at.toISOString(), results }, null, 2))
268
+ return file
269
+ }
270
+
271
+ /** The pass rate of each case in the latest earlier results that ran it: "prompt/case" → [passed, runs]. */
272
+ export function previousRates(config: AppConfig, except?: string): Map<string, [number, number]> {
273
+ const dir = resultsDir(config)
274
+ const out = new Map<string, [number, number]>()
275
+ if (!existsSync(dir)) return out
276
+ const files = readdirSync(dir).filter((f) => f.endsWith(".json")).sort().reverse()
277
+ for (const f of files) {
278
+ if (except && path.join(dir, f) === except) continue
279
+ try {
280
+ const { results } = JSON.parse(readFileSync(path.join(dir, f), "utf8")) as { results: CaseResult[] }
281
+ for (const r of results) {
282
+ const key = `${r.prompt}/${r.case}`
283
+ if (!out.has(key) && r.runs.length) out.set(key, [r.runs.filter((x) => x.pass).length, r.runs.length])
284
+ }
285
+ } catch {}
286
+ }
287
+ return out
288
+ }
package/src/mcp/server.ts CHANGED
@@ -42,6 +42,14 @@ export function createMcpServer(opts: McpServerOptions) {
42
42
  const timer = token !== undefined ? setInterval(() => send({ jsonrpc: "2.0", method: "notifications/progress", params: { progressToken: token, progress: ++tick, message: `${tool.name} running` } }), opts.progressMs || PROGRESS_EVERY_MS) : null
43
43
  const started = Date.now()
44
44
  logEvent("tool.call", { tool: tool.name, arguments: params.arguments || {} })
45
+ base.observe?.(tool.name, params.arguments || {})
46
+ if (base.allow && !base.allow(tool.name)) {
47
+ running.delete(id)
48
+ if (timer) clearInterval(timer)
49
+ base.signal?.removeEventListener("abort", abort)
50
+ outer?.removeEventListener("abort", abort)
51
+ return { content: [{ type: "text", text: `${tool.name} is not available in this evaluation. Say what you would do with it, and stop.` }], isError: true }
52
+ }
45
53
  const finish = (result: Json) => {
46
54
  logEvent("tool.result", { tool: tool.name, ms: Date.now() - started, error: Boolean(result.isError), text: result.content?.[0]?.text })
47
55
  return result
@@ -54,8 +62,18 @@ export function createMcpServer(opts: McpServerOptions) {
54
62
  base.emit?.(e)
55
63
  }
56
64
  : undefined
57
- const out = await tool.run(params.arguments || {}, { ...base, emit, signal: controller.signal })
65
+ const said = (ok: boolean) => {
66
+ if (tool.line && emit) emit({ type: "call", text: tool.line(params.arguments || {}), ok })
67
+ }
68
+ let out: unknown
69
+ try {
70
+ out = await tool.run(params.arguments || {}, { ...base, emit, signal: controller.signal })
71
+ } catch (err) {
72
+ if (!controller.signal.aborted) said(false)
73
+ throw err
74
+ }
58
75
  if (controller.signal.aborted) return finish({ content: [{ type: "text", text: "Stopped by the user." }], isError: true })
76
+ said(true)
59
77
  return finish({ content: [{ type: "text", text: typeof out === "string" ? out : JSON.stringify(out, null, 2) }] })
60
78
  } catch (err) {
61
79
  return finish({ content: [{ type: "text", text: controller.signal.aborted ? "Stopped by the user." : (err as Error).message || String(err) }], isError: true })
package/src/mcp/tools.ts CHANGED
@@ -98,6 +98,25 @@ export type ToolEvent =
98
98
  | { type: "card"; card: Card }
99
99
  /** write_report saved a report: its page and its Markdown. */
100
100
  | { type: "report"; title: string; html: string; markdown: string }
101
+ /** A finished call of a tool that has no card of its own, as one line: "Azure DevOps API · QI-Claims/_apis/build/builds (top 5)". */
102
+ | { type: "call"; text: string; ok: boolean }
103
+
104
+ const API_LABEL: Record<string, string> = { azure_devops: "Azure DevOps", jira: "Jira", xray: "Xray", confluence: "Confluence" }
105
+
106
+ /**
107
+ * A query_api call as a line: "Azure DevOps API · QI-Claims/_apis/release/releases (top 1, queryOrder descending)". The
108
+ * host and organization are left out, as are api-version and the start of a WIQL query.
109
+ */
110
+ export function queryLine(args: Record<string, any>): string {
111
+ const name = `${API_LABEL[String(args.system)] || String(args.system || "API")} API`
112
+ if (args.wiql) return `${name} · WIQL (${clip(String(args.wiql).replace(/\s+/g, " ").replace(/^SELECT .*? FROM WorkItems WHERE /i, ""), 90)})`
113
+ if (args.query) return `${name} · GraphQL (${clip(String(args.query).replace(/\s+/g, " "), 90)})`
114
+ const raw = String(args.path || "")
115
+ const url = /^https?:\/\//.test(raw) ? raw.replace(/^https?:\/\/[^/]+\/(?:[^/]+\/(?=[^/]+\/_apis\/))?/, "") : raw.replace(/^\//, "")
116
+ const [where, search = ""] = url.split("?")
117
+ const params = [...new URLSearchParams(search)].filter(([k]) => k !== "api-version").map(([k, v]) => `${k.replace(/^\$/, "")} ${v}`)
118
+ return `${name} · ${decodeURIComponent(where || "")}${params.length ? ` (${clip(params.join(", "), 90)})` : ""}`
119
+ }
101
120
 
102
121
  export type ToolContext = {
103
122
  config: AppConfig
@@ -111,6 +130,10 @@ export type ToolContext = {
111
130
  url?: string
112
131
  signal?: AbortSignal
113
132
  emit?: (e: ToolEvent) => void
133
+ /** Sees every call before it runs, with its arguments: evaluations record which tools the agent chose. */
134
+ observe?: (tool: string, args: Record<string, unknown>) => void
135
+ /** Whether a tool may run; a refused call answers that it is not available here (evaluations refuse tools that act). */
136
+ allow?: (tool: string) => boolean
114
137
  /** How long this process lives: the shell, one `forgeqe run`, or `forgeqe mcp`. Background monitors need shell or server. */
115
138
  session?: "shell" | "once" | "server"
116
139
  /** Passed browser runs become tests in cwd (the shell and `forgeqe run` without --no-code). */
@@ -135,6 +158,8 @@ export type McpTool = {
135
158
  description: string
136
159
  inputSchema: Record<string, unknown>
137
160
  run(args: Record<string, any>, ctx: ToolContext): Promise<unknown>
161
+ /** The call as one line in the conversation, for a tool that shows nothing else: the ForgeQE tools are hidden there. */
162
+ line?(args: Record<string, any>): string
138
163
  }
139
164
 
140
165
  const clip = (s: string | undefined, n: number) => {
@@ -563,6 +588,7 @@ export const TOOLS: McpTool[] = [
563
588
  limit: { type: "integer", minimum: 1, maximum: 25, description: "Most pages to return (default 10)" },
564
589
  },
565
590
  },
591
+ line: (args) => `Confluence · ${args.cql ? `search (${clip(String(args.cql), 90)})` : `pages (${clip((Array.isArray(args.pages) ? args.pages : []).map(String).join(", "), 90)})`}`,
566
592
  async run(args, ctx) {
567
593
  const pages = Array.isArray(args.pages) ? args.pages.map(String) : undefined
568
594
  return readConfluence(ctx.config.sources, { pages, cql: args.cql ? String(args.cql) : undefined, limit: Number(args.limit) || undefined })
@@ -592,6 +618,7 @@ export const TOOLS: McpTool[] = [
592
618
  },
593
619
  required: ["system"],
594
620
  },
621
+ line: queryLine,
595
622
  async run(args, ctx) {
596
623
  return queryApi(ctx.config.sources, { system: args.system, path: args.path, query: args.query, variables: args.variables, wiql: args.wiql, project: args.project })
597
624
  },
package/src/prompts.ts CHANGED
@@ -35,6 +35,8 @@ export type PromptInfo = {
35
35
  name: string
36
36
  description: string
37
37
  usedBy: string
38
+ /** What kind of prompt it is, which decides the standard it is checked against (src/promptstyle.ts). */
39
+ kind: string
38
40
  source: PromptSource | string
39
41
  file: string
40
42
  /** An edited copy whose built-in prompt changed since the copy was made. */
@@ -103,7 +105,7 @@ export function listPrompts(): PromptInfo[] {
103
105
  const name = f.replace(/\.md$/, "")
104
106
  const { meta } = parse(readFileSync(path.join(PROMPTS_DIR, f), "utf8"))
105
107
  const where = promptFile(name)
106
- return { name, description: meta.description || "", usedBy: meta.used_by || "", source: where.source, file: where.file, outdated: where.outdated }
108
+ return { name, description: meta.description || "", usedBy: meta.used_by || "", kind: meta.kind || "", source: where.source, file: where.file, outdated: where.outdated }
107
109
  })
108
110
  }
109
111
 
@@ -114,12 +116,13 @@ export function promptWarnings(): string[] {
114
116
  .map((p) => `Your prompt ${p.name} (${p.file}) is based on an older version of ForgeQE's; forgeqe prompts diff ${p.name} shows what changed.`)
115
117
  }
116
118
 
117
- /** The browser engine's prompts (browser-*.md), resolved, for the engine to use instead of its own copies. */
119
+ /** The browser engine's prompts (browser-*.md and conventions.md), resolved, for the engine to use instead of its own copies. */
118
120
  export function enginePrompts(): Record<string, string> {
119
121
  const out: Record<string, string> = {}
120
122
  for (const f of readdirSync(PROMPTS_DIR)) {
121
123
  const name = f.replace(/\.md$/, "")
122
- if (!f.endsWith(".md") || !name.startsWith("browser-") || name === "browser-extra") continue
124
+ // The browser agent's prompts, and the conventions that state how their rules read.
125
+ if (!f.endsWith(".md") || !(name.startsWith("browser-") || name === "conventions") || name === "browser-extra") continue
123
126
  out[name] = prompt(name)
124
127
  }
125
128
  return out
@@ -0,0 +1,159 @@
1
+ import { steParagraphs, steScore, STE_TARGET, RFC_LEAD } from "./ste.ts"
2
+
3
+ /**
4
+ * The prompting standard every ForgeQE prompt follows, and what of it can be checked in the text. Each technique is
5
+ * used where it fits the kind of prompt (frontmatter `kind`):
6
+ *
7
+ * | kind | what it is | structure | few-shot | execution contract |
8
+ * |------------|------------------------------------------------|-------------------------------------------------------------|----------|--------------------|
9
+ * | agent | works with tools over many steps | Role, Objective, Execution contract, Rules, Output | optional | required |
10
+ * | structured | one answer in a fixed shape (plans, verdicts) | Role, Objective, Inputs, Rules, Output, Examples | required | no |
11
+ * | fragment | rules included in other prompts | Rules | no | no |
12
+ * | user | the user's own text (browser-extra) | not checked | no | no |
13
+ *
14
+ * Every kind but user is written in ASD-STE100 (src/ste.ts) and states its rules with the RFC 2119 key words: MUST and
15
+ * MUST NOT for absolute rules, SHOULD and SHOULD NOT for strong defaults, MAY for a free choice. A lower-case "must",
16
+ * "should", or "shall" is ambiguous, so it is flagged. Evaluation cases (evals/prompts/<name>/) measure how reliably
17
+ * a prompt does its task; `forgeqe prompts eval` runs them.
18
+ */
19
+
20
+ export type PromptKind = "agent" | "structured" | "fragment" | "user"
21
+ export const PROMPT_KINDS: PromptKind[] = ["agent", "structured", "fragment", "user"]
22
+
23
+ /** The `##` sections each kind has, in this order. Other sections may come between them. */
24
+ export const SECTIONS: Record<PromptKind, string[]> = {
25
+ agent: ["Role", "Objective", "Execution contract", "Rules", "Output"],
26
+ structured: ["Role", "Objective", "Inputs", "Rules", "Output", "Examples"],
27
+ fragment: [],
28
+ user: [],
29
+ }
30
+
31
+ /** The parts of an execution contract, each a bold label or a `###` heading inside it. */
32
+ export const CONTRACT_PARTS = ["Preconditions", "Tools", "Procedure", "Stop when", "On failure", "Evidence"]
33
+
34
+ /** Fewest worked examples a structured prompt gives. */
35
+ export const MIN_EXAMPLES = 2
36
+
37
+ /** Share of rule lines (list items under a Rules section) that carry an RFC 2119 key word. */
38
+ export const RFC_TARGET = 0.8
39
+
40
+ const KEYWORD = /\b(MUST NOT|MUST|SHOULD NOT|SHOULD|MAY|SHALL NOT|SHALL|REQUIRED|RECOMMENDED|OPTIONAL)\b/
41
+ /** Lower-case words that read like a rule but say nothing of its strength. */
42
+ const AMBIGUOUS = /\b(must|should|shall)\b/
43
+
44
+ export type StyleIssue = { rule: "ste" | "rfc" | "ambiguous" | "section" | "order" | "examples" | "contract" | "kind"; detail: string }
45
+
46
+ export type StyleReport = {
47
+ kind: PromptKind
48
+ ste: number
49
+ /** Rule lines with a key word, of all rule lines; 1 when the prompt has none. */
50
+ rfc: number
51
+ ruleLines: number
52
+ examples: number
53
+ issues: StyleIssue[]
54
+ }
55
+
56
+ /** The `##` headings of a prompt, in order, without code blocks. */
57
+ export function headings(markdown: string, level = 2): string[] {
58
+ const out: string[] = []
59
+ let fence = false
60
+ const mark = `${"#".repeat(level)} `
61
+ for (const line of markdown.split("\n")) {
62
+ if (/^\s*(```|~~~)/.test(line)) fence = !fence
63
+ else if (!fence && line.startsWith(mark)) out.push(line.slice(mark.length).trim())
64
+ }
65
+ return out
66
+ }
67
+
68
+ /** The text under a `##` heading, up to the next `##` heading. */
69
+ export function section(markdown: string, name: string): string {
70
+ const lines = markdown.split("\n")
71
+ const at = lines.findIndex((l) => l.trim().toLowerCase() === `## ${name}`.toLowerCase())
72
+ if (at < 0) return ""
73
+ const end = lines.findIndex((l, i) => i > at && /^## /.test(l))
74
+ return lines.slice(at + 1, end < 0 ? undefined : end).join("\n")
75
+ }
76
+
77
+ /** Text with code, quotes, and placeholders taken out: a quoted example is not a rule. */
78
+ const prose = (text: string) =>
79
+ text
80
+ .replace(/```[\s\S]*?```/g, "")
81
+ .replace(/`[^`]*`/g, "")
82
+ .replace(/"[^"\n]*"/g, "")
83
+ .replace(/“[^”\n]*”/g, "")
84
+ .replace(/\{\{[^}]*\}\}/g, "")
85
+
86
+ /**
87
+ * The rules of the Rules sections (and of a fragment's whole text). A list item is one rule, with what is nested under
88
+ * it as its details; only an item without a key word that opens a list ("Write replies so that they read quickly:")
89
+ * hands its nested items on, each a rule of its own.
90
+ */
91
+ export function ruleLines(markdown: string, kind: PromptKind): string[] {
92
+ const parts = kind === "fragment" ? [markdown] : headings(markdown).filter((h) => /rules/i.test(h)).map((h) => section(markdown, h))
93
+ const items: string[] = []
94
+ for (const part of parts) {
95
+ // The indent of the items that count as rules at each depth; deeper items under a rule are its details.
96
+ let ruleIndent = -1
97
+ for (const line of prose(part).split("\n")) {
98
+ const m = /^(\s*)(?:[-*+]|\d+[.)])\s+(.*)$/.exec(line)
99
+ if (!m) continue
100
+ const indent = m[1]!.length
101
+ const text = m[2]!.trim()
102
+ if (!text) continue
103
+ if (ruleIndent >= 0 && indent > ruleIndent) continue
104
+ // A numbered step is a MUST by the conventions, unless it says SHOULD or MAY.
105
+ const numbered = /^\s*\d+[.)]/.test(line)
106
+ const opensList = /:\s*$/.test(text) && !KEYWORD.test(text) && !numbered
107
+ if (opensList) {
108
+ ruleIndent = -1
109
+ continue
110
+ }
111
+ items.push(numbered && !KEYWORD.test(text) ? `MUST ${text}` : text)
112
+ ruleIndent = indent
113
+ }
114
+ }
115
+ return items
116
+ }
117
+
118
+ export function checkStyle(markdown: string, kind: PromptKind): StyleReport {
119
+ const issues: StyleIssue[] = []
120
+ if (kind === "user") return { kind, ste: 1, rfc: 1, ruleLines: 0, examples: 0, issues }
121
+ const ste = steScore(markdown).score
122
+ if (ste < STE_TARGET) issues.push({ rule: "ste", detail: `${Math.round(ste * 100)}% of sentences keep ASD-STE100; the target is ${Math.round(STE_TARGET * 100)}%` })
123
+
124
+ const rules = ruleLines(markdown, kind)
125
+ const keyed = rules.filter((r) => KEYWORD.test(r)).length
126
+ const rfc = rules.length ? keyed / rules.length : 1
127
+ if (rules.length && rfc < RFC_TARGET) issues.push({ rule: "rfc", detail: `${keyed} of ${rules.length} rules use an RFC 2119 key word; the target is ${Math.round(RFC_TARGET * 100)}%` })
128
+ for (const sentence of steParagraphs(prose(markdown)).flat()) {
129
+ const m = AMBIGUOUS.exec(sentence)
130
+ if (m) issues.push({ rule: "ambiguous", detail: `"${m[0]}" in: ${sentence.slice(0, 100)} — use ${m[0].toUpperCase()} (RFC 2119), or say it as a fact` })
131
+ }
132
+
133
+ const have = headings(markdown)
134
+ const need = SECTIONS[kind]
135
+ for (const name of need) if (!have.some((h) => h.toLowerCase() === name.toLowerCase())) issues.push({ rule: "section", detail: `no "## ${name}" section` })
136
+ const order = need.map((n) => have.findIndex((h) => h.toLowerCase() === n.toLowerCase())).filter((i) => i >= 0)
137
+ if (order.some((v, i) => i > 0 && v < order[i - 1]!)) issues.push({ rule: "order", detail: `sections out of order; use ${need.join(", ")}` })
138
+
139
+ const examples = headings(markdown, 3).filter((h) => /^example\b/i.test(h)).length
140
+ if (kind === "structured" && examples < MIN_EXAMPLES) issues.push({ rule: "examples", detail: `${examples} worked examples ("### Example …"); a structured prompt gives ${MIN_EXAMPLES} or more` })
141
+
142
+ if (kind === "agent") {
143
+ const contract = section(markdown, "Execution contract")
144
+ for (const part of CONTRACT_PARTS) {
145
+ const label = new RegExp(`(\\*\\*${part}\\*\\*|^###\\s+${part}\\b)`, "im")
146
+ if (contract && !label.test(contract)) issues.push({ rule: "contract", detail: `the execution contract has no "${part}" part` })
147
+ }
148
+ }
149
+ return { kind, ste, rfc, ruleLines: rules.length, examples, issues }
150
+ }
151
+
152
+ export function promptKind(value: string | undefined): PromptKind | undefined {
153
+ return PROMPT_KINDS.find((k) => k === value)
154
+ }
155
+
156
+ /** Whether a sentence states a rule with an RFC 2119 key word ("You MUST …", "… MUST NOT …"). */
157
+ export function isRule(sentence: string): boolean {
158
+ return RFC_LEAD.test(sentence.trim()) || KEYWORD.test(sentence)
159
+ }