@capagents/forgeqe 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/HOW_TO_USE.md +39 -2
  2. package/README.md +1 -1
  3. package/docs/COST_ARCHITECTURE.md +280 -0
  4. package/docs/feature-test-prompts.md +95 -0
  5. package/docs/user-guide.html +53 -6
  6. package/forgeqe.example.yaml +15 -0
  7. package/package.json +1 -1
  8. package/prompts/brain.md +35 -4
  9. package/prompts/browser-checks.md +2 -0
  10. package/py/forgeqe_engine/checks.py +8 -2
  11. package/py/forgeqe_engine/llm.py +33 -5
  12. package/py/forgeqe_engine/memory.py +59 -2
  13. package/py/forgeqe_engine/observe.py +123 -0
  14. package/py/forgeqe_engine/replay.py +55 -2
  15. package/py/forgeqe_engine/service.py +155 -3
  16. package/src/capabilities/registry.ts +19 -5
  17. package/src/cases/ado.ts +1 -1
  18. package/src/cases/journey.ts +61 -0
  19. package/src/cases/readiness.ts +35 -6
  20. package/src/cases/save.ts +241 -48
  21. package/src/cases/types.ts +11 -0
  22. package/src/cli/help.ts +8 -0
  23. package/src/cli/index.ts +64 -1
  24. package/src/code/coverage.ts +22 -3
  25. package/src/code/decisions.ts +73 -0
  26. package/src/code/find.ts +3 -1
  27. package/src/code/graph/graph.ts +33 -2
  28. package/src/code/loop.ts +17 -1
  29. package/src/code/notes.ts +48 -1
  30. package/src/code/prompt.ts +31 -9
  31. package/src/code/protect.ts +103 -0
  32. package/src/code/review.ts +2 -0
  33. package/src/code/runtime.ts +35 -3
  34. package/src/code/shortlist.ts +309 -0
  35. package/src/code/testrun.ts +3 -1
  36. package/src/code/traceability.ts +2 -0
  37. package/src/commands.ts +6 -1
  38. package/src/config.ts +46 -0
  39. package/src/engine/bridge.ts +47 -5
  40. package/src/mcp/casetools.ts +107 -11
  41. package/src/mcp/commands.ts +74 -20
  42. package/src/mcp/packtools.ts +187 -0
  43. package/src/mcp/tools.ts +238 -29
  44. package/src/pack/collect.ts +251 -0
  45. package/src/pack/index.ts +134 -0
  46. package/src/pack/init.ts +45 -0
  47. package/src/pack/pack.ts +327 -0
  48. package/src/pack/pipeline.ts +373 -0
  49. package/src/pack/run.ts +155 -0
  50. package/src/registry/db.ts +249 -0
  51. package/src/registry/index.ts +72 -0
  52. package/src/registry/resolve.ts +57 -0
  53. package/src/registry/tier0.ts +81 -0
  54. package/src/report/data.ts +7 -0
  55. package/src/report/html.ts +14 -0
  56. package/src/report/template.html +52 -48
  57. package/src/report/usage.ts +113 -0
  58. package/src/run.ts +22 -2
  59. package/src/suite/run.ts +12 -5
  60. package/src/summary.ts +1 -0
  61. package/src/tokenomics/budget.ts +172 -0
  62. package/src/tokenomics/ledger.ts +287 -0
  63. package/src/tui/App.tsx +89 -2
  64. package/src/tui/inspector.tsx +40 -35
  65. package/src/types.ts +9 -2
@@ -6,7 +6,8 @@ import type { TestCase } from "../cases/types.ts"
6
6
  import type { AppConfig } from "../config.ts"
7
7
  import { readPages } from "../memory.ts"
8
8
  import { DEFAULT_THRESHOLDS, releaseReadiness, saveReadiness, type Readiness } from "../cases/readiness.ts"
9
- import { updateAdoCase } from "../cases/save.ts"
9
+ import { jiraCase, storyCases, updateAdoCase, updateJiraCase, type NewCase } from "../cases/save.ts"
10
+ import { planJourneys, type JourneyCase } from "../cases/journey.ts"
10
11
  import { writeAdoWikiPage } from "../cases/wiki.ts"
11
12
  import type { McpTool, ToolContext } from "./tools.ts"
12
13
  import { notifiedLine, notify, readinessNotice } from "../notify/events.ts"
@@ -106,11 +107,11 @@ export const CASE_TOOLS: McpTool[] = [
106
107
  {
107
108
  name: "update_test_case",
108
109
  description:
109
- "Rewrite an Azure DevOps Test Case's steps (and optionally its title) after the user agrees: ForgeQE shows the current and the new steps and asks (Update / Don't update); the reason goes into the work item's history, and the old steps stay there. Use it for a case run_test_cases classified as test case needs updating (write the steps from what the run found the application does: run_details has every step and element), or a case check_test_cases found unclear. Where ForgeQE cannot ask (an MCP client), pass confirmed: true only after the user agreed to these exact steps.",
110
+ "Rewrite a test case's steps (and optionally its title) in place after the user agrees: an Azure DevOps Test Case (the reason goes into its history, the old steps stay there) or a Jira test issue (the steps in its description are replaced, the text above them kept, and the reason added as a comment). ForgeQE shows the current and the new steps and asks (Update / Don't update). Use it for a case run_test_cases classified as test case needs updating (write the steps from what the run found the application does: run_details has every step and element), a case check_test_cases found unclear, a change the user asks for after reviewing a case, and a case save_test_cases returned as differs or similar. The case's automated test, when it has one, is brought up to date on its next run. Where ForgeQE cannot ask (an MCP client), pass confirmed: true only after the user agreed to these exact steps.",
110
111
  inputSchema: {
111
112
  type: "object",
112
113
  properties: {
113
- id: { type: "string", description: "The test case: ADO-2236, 2236, or its link" },
114
+ id: { type: "string", description: "The test case: ADO-2236, 2236, or its link in Azure DevOps; SHOP-31 in Jira" },
114
115
  title: { type: "string", description: "A new title, only when the old one no longer fits" },
115
116
  steps: {
116
117
  type: "array",
@@ -123,17 +124,23 @@ export const CASE_TOOLS: McpTool[] = [
123
124
  required: ["id", "steps", "reason"],
124
125
  },
125
126
  async run(args, ctx) {
126
- const id = Number(/(\d+)\s*$/.exec(String(args.id).replace(/[/?#].*$/, ""))?.[1] || /edit\/(\d+)/.exec(String(args.id))?.[1])
127
- if (!id) throw new Error(`"${args.id}" is not an Azure DevOps test case id.`)
128
- if (!ctx.config.sources.ado) throw new Error("Azure DevOps is not set up: updating test cases works for Azure DevOps Test Cases.")
127
+ const raw = String(args.id).trim()
128
+ const jiraKey = /^(?!ADO-)[A-Z][A-Z0-9_]*-\d+$/i.test(raw) ? raw.toUpperCase() : /\/browse\/([A-Z][A-Z0-9_]*-\d+)/i.exec(raw)?.[1]?.toUpperCase()
129
+ const id = jiraKey ? 0 : Number(/(\d+)\s*$/.exec(raw.replace(/[/?#].*$/, ""))?.[1] || /edit\/(\d+)/.exec(raw)?.[1])
130
+ if (!jiraKey && !id) throw new Error(`"${args.id}" is not an Azure DevOps test case id or a Jira issue key.`)
131
+ if (jiraKey && !ctx.config.sources.jira) throw new Error("Jira is not set up: add sources.jira to forgeqe.yaml.")
132
+ if (!jiraKey && !ctx.config.sources.ado) throw new Error("Azure DevOps is not set up: add sources.azure_devops to forgeqe.yaml.")
129
133
  const steps = (args.steps as { action: string; expected?: string }[]).filter((s) => s?.action?.trim()).map((s) => ({ action: s.action.trim(), ...(s.expected?.trim() ? { expected: s.expected.trim() } : {}) }))
130
134
  if (!steps.length) throw new Error("steps is empty.")
131
- const [current] = await casesFrom([{ source: "azure_devops", ids: [String(id)] } as CaseSelector], ctx.config.sources, ctx.cwd)
135
+ const current: { steps: NewCase["steps"]; url?: string } | undefined = jiraKey
136
+ ? await jiraCase(ctx.config.sources, jiraKey)
137
+ : (await casesFrom([{ source: "azure_devops", ids: [String(id)] } as CaseSelector], ctx.config.sources, ctx.cwd))[0]
138
+ const label = jiraKey || String(id)
132
139
  const show = (list: { action: string; expected?: string }[]) => list.map((s, i) => ` ${i + 1}. ${s.action}${s.expected ? ` → ${s.expected}` : ""}`)
133
140
  const lines = ["Now:", ...show(current?.steps || []), "", "New:", ...show(steps), "", `Why: ${args.reason}`]
134
141
  if (ctx.ask) {
135
142
  const choice = await ctx.ask({
136
- title: `Update test case ${id}${args.title ? ` and rename it "${args.title}"` : ""}?`,
143
+ title: `Update test case ${label}${args.title ? ` and rename it "${args.title}"` : ""}?`,
137
144
  detail: current?.url,
138
145
  lines,
139
146
  options: [
@@ -145,9 +152,98 @@ export const CASE_TOOLS: McpTool[] = [
145
152
  } else if (args.confirmed !== true) {
146
153
  return { updated: false, needs_confirmation: true, current: current?.steps, proposed: steps, next_step: "Show the user the current and new steps; call update_test_case again with confirmed: true only after they agree." }
147
154
  }
148
- const url = await updateAdoCase(ctx.config.sources, id, { title: args.title ? String(args.title) : undefined, steps, reason: String(args.reason) })
149
- ctx.emit?.({ type: "card", card: { status: "ok", title: `Test case ${id} updated`, stats: `${steps.length} steps`, body: [...show(steps), "", `Why: ${args.reason}`, url] } })
150
- return { updated: true, url, steps: steps.length, next_step: "Offer to run it again with run_test_cases." }
155
+ const change = { title: args.title ? String(args.title) : undefined, steps, reason: String(args.reason) }
156
+ const url = jiraKey ? await updateJiraCase(ctx.config.sources, jiraKey, change) : await updateAdoCase(ctx.config.sources, id, change)
157
+ ctx.emit?.({ type: "card", card: { status: "ok", title: `Test case ${label} updated`, stats: `${steps.length} steps`, body: [...show(steps), "", `Why: ${args.reason}`, url] } })
158
+ return { updated: true, url, steps: steps.length, next_step: "Offer to run it again with run_test_cases; its automated test, when it has one, is updated to the new steps in that run." }
159
+ },
160
+ },
161
+ {
162
+ name: "story_test_cases",
163
+ description:
164
+ "The test cases already linked to an Azure DevOps or Jira story, with their steps and expected results. Call it before writing test cases for a story, so you write only the cases its acceptance criteria still need, and propose updates (update_test_case) to linked cases that no longer fit, instead of writing them again. Reads only.",
165
+ inputSchema: {
166
+ type: "object",
167
+ properties: {
168
+ system: { type: "string", enum: ["azure_devops", "jira"], description: "Where the story is" },
169
+ story: { type: "string", description: "The story's work item id (Azure DevOps) or issue key (Jira)" },
170
+ },
171
+ required: ["system", "story"],
172
+ },
173
+ async run(args, ctx) {
174
+ const story = String(args.story || "").trim()
175
+ if (!story) throw new Error("story is required.")
176
+ const found = await storyCases(ctx.config.sources, args.system === "jira" ? "jira" : "azure_devops", story)
177
+ ctx.emit?.({
178
+ type: "card",
179
+ card: {
180
+ status: "info",
181
+ title: `Test cases linked to ${found.story.id}${found.story.title ? ` ${found.story.title}` : ""}`,
182
+ stats: `${found.cases.length} linked`,
183
+ body: found.cases.length ? found.cases.map((c) => `${c.id} ${c.title} (${c.steps.length} steps)`) : ["None yet."],
184
+ },
185
+ })
186
+ return {
187
+ story: found.story,
188
+ cases: found.cases.map((c) => ({ id: c.id, title: c.title, url: c.url, steps: c.steps.map((st, i) => `${i + 1}. ${st.action}${st.expected ? ` → ${st.expected}` : ""}`) })),
189
+ next_step: found.cases.length
190
+ ? "Compare these with the story's acceptance criteria. Write only the cases for criteria and rules no linked case covers, and save only those. For a linked case that no longer fits the story, propose its new steps with update_test_case. Tell the user which criteria the linked cases already cover."
191
+ : "No case is linked yet: write the cases from the story.",
192
+ }
193
+ },
194
+ },
195
+ {
196
+ name: "e2e_journey",
197
+ description:
198
+ "Plan end-to-end journeys from test cases that already exist, instead of writing them again: from the cases linked to a story (system and story) or any find_test_cases sources. Groups the cases that start the same way (same start page and first steps, such as signing in), keeps those shared steps once, and lays out each case's remaining steps as a section of one journey, with which case and steps each section comes from. Flags cases that look like negative paths, which may end the session. Writes nothing.",
199
+ inputSchema: {
200
+ type: "object",
201
+ properties: {
202
+ system: { type: "string", enum: ["azure_devops", "jira"], description: "With story: the story's system" },
203
+ story: { type: "string", description: "A story whose linked cases to use" },
204
+ sources: { type: "array", description: "Or the same sources as find_test_cases", items: { type: "object" } },
205
+ },
206
+ },
207
+ async run(args, ctx) {
208
+ let cases: JourneyCase[]
209
+ let story: { id: string; title: string; url: string } | undefined
210
+ if (args.story) {
211
+ const found = await storyCases(ctx.config.sources, args.system === "jira" ? "jira" : "azure_devops", String(args.story).trim())
212
+ story = found.story
213
+ cases = found.cases.map((c) => ({ id: c.id, title: c.title, url: c.url, steps: c.steps }))
214
+ } else if (Array.isArray(args.sources) && args.sources.length) {
215
+ cases = (await casesFrom(args.sources as CaseSelector[], ctx.config.sources, ctx.cwd)).map((c) => ({ id: c.id, title: c.title, url: c.url, startUrl: c.startUrl, steps: c.steps }))
216
+ } else throw new Error("Give a story (with system), or sources.")
217
+ const plan = planJourneys(cases)
218
+ ctx.emit?.({
219
+ type: "card",
220
+ card: {
221
+ status: plan.journeys.length ? "ok" : "info",
222
+ title: `End-to-end journeys${story ? ` for ${story.id}` : ""}`,
223
+ stats: `${plan.journeys.length} journeys from ${cases.length} cases · ${plan.stepsSaved} repeated steps saved`,
224
+ body: [
225
+ ...plan.journeys.flatMap((j, n) => [
226
+ `Journey ${n + 1}: ${j.shared.length} shared steps, then ${j.sections.length} cases`,
227
+ ...j.sections.map((sec) => ` ${sec.negative ? "▲" : "·"} ${sec.caseId} ${sec.title} (steps ${sec.from}-${sec.to})`),
228
+ ]),
229
+ ...(plan.alone.length ? ["", `On their own: ${plan.alone.map((c) => c.id).join(", ")}`] : []),
230
+ ],
231
+ },
232
+ })
233
+ return {
234
+ ...(story ? { story } : {}),
235
+ journeys: plan.journeys.map((j) => ({
236
+ start_url: j.startUrl,
237
+ shared_steps: j.shared.length,
238
+ steps: j.steps.map((st, i) => `${i + 1}. ${st.action}${st.expected ? ` → ${st.expected}` : ""}`),
239
+ sections: j.sections.map((sec) => ({ case: sec.caseId, title: sec.title, steps: `${sec.from}-${sec.to}`, ...(sec.negative ? { negative: true } : {}) })),
240
+ repeated_steps_saved: j.saved,
241
+ })),
242
+ on_their_own: plan.alone.map((c) => ({ case: c.id, title: c.title })),
243
+ next_step: plan.journeys.length
244
+ ? "Order the sections so each leaves the application in the state the next one needs. A negative section (an error, a refusal, signing out, deleting shared data) goes last or stays its own case. Show the user each journey with the cases it covers. When they agree, save each as one case with save_test_cases, titled \"E2E: …\", with a description naming the cases it covers; keep the original cases. Run a journey instead of its separate cases only when the user asks for an end-to-end run."
245
+ : "The cases share no first steps, so a journey saves nothing: offer to run them as they are.",
246
+ }
151
247
  },
152
248
  },
153
249
  {
@@ -1,6 +1,7 @@
1
- import { coverageLines, EXTEND_FROM, measureCoverage, type Candidate } from "../code/coverage.ts"
1
+ import { coverageLines, EXTEND_FROM, measureCoverage, settle, type Candidate } from "../code/coverage.ts"
2
2
  import { buildGraph } from "../code/graph/graph.ts"
3
- import { EXISTING_TEST_FILE } from "../code/notes.ts"
3
+ import { EXISTING_TEST_FILE, EXTEND_TEST_FILE } from "../code/notes.ts"
4
+ import { recordDecision, type DecisionRecord } from "../code/decisions.ts"
4
5
  import type { ExistingTest, TestTarget } from "../code/prompt.ts"
5
6
  import { existsSync, readFileSync } from "node:fs"
6
7
  import path from "node:path"
@@ -33,19 +34,54 @@ const clip = (s: string | undefined, n: number) => {
33
34
  /** Marks a browser_run that was a task (a lookup in a tool), not a flow to automate; write_test refuses such runs. */
34
35
 
35
36
  /**
36
- * Reuse, extend, or new, from what the repository's tests already cover of the run (code graph and page memory).
37
- * The clear cases go ahead and say why; where the evidence fits more than one way, the user picks, seeing each
38
- * option's effect. A mode the user gave (through ForgeQE's agent) is followed without asking.
37
+ * Reuse, extend, add a test beside one, or new, from what the repository's tests already cover of the run (code graph
38
+ * and page memory). The clear cases go ahead and say why; where the evidence fits more than one way, the user picks,
39
+ * seeing each option's effect, and with no one to ask a fixed policy decides (settle). A mode the user gave is followed
40
+ * as it is; one ForgeQE's agent chose before the browser run is followed too, but a test is not made overlong.
39
41
  */
40
42
  export async function chooseTarget(runDir: string, repo: string, args: Record<string, any>, ctx: ToolContext): Promise<{ target?: TestTarget } | { cancelled: true }> {
41
- const asked = typeof args.mode === "string" && args.mode !== "auto" ? (args.mode as "new" | "extend" | "reuse" | "update") : null
42
- if (asked === "new") return {}
43
+ const asked = typeof args.mode === "string" && args.mode !== "auto" ? (args.mode as "new" | "extend" | "reuse" | "update" | "sibling") : null
44
+ // ForgeQE's agent chose it before the browser run (args.why); otherwise the user named it.
45
+ const byAgent = Boolean(args.why)
46
+ const goal = ((): string => {
47
+ try {
48
+ return (JSON.parse(readFileSync(path.join(runDir, "run.json"), "utf8")) as { goal?: string }).goal || ""
49
+ } catch {
50
+ return ""
51
+ }
52
+ })()
53
+ const note = (by: NonNullable<DecisionRecord["by"]>, mode: NonNullable<DecisionRecord["mode"]>, c: Candidate | undefined, why: string) =>
54
+ recordDecision(repo, { goal, stage: "chosen", where: "write_test", by, mode, ...(c ? { test: c.test, file: c.file } : {}), why, run_dir: runDir })
55
+ if (asked === "new") {
56
+ note(byAgent ? "agent" : "user", "new", undefined, "asked for a new test")
57
+ return {}
58
+ }
43
59
  const graph = await buildGraph(repo).catch(() => null)
44
60
  if (!graph) return {}
45
61
  const cov = measureCoverage(runDir, graph, ctx.config.memory.dir)
46
- const named = args.test_file ? cov.candidates.find((c) => c.file === String(args.test_file).replace(/^\.\//, "")) : undefined
62
+ const file = args.test_file ? String(args.test_file).replace(/^\.\//, "") : ""
63
+ // A test named but not measured (its page objects do not locate this run's elements) is still the one to work from.
64
+ const measurable = cov.items.flatMap((it, i) => (it.members.length ? [i] : []))
65
+ // In a file of many tests, the one named (Class.Method, or its method name) when there is one.
66
+ const testName = args.test_name ? String(args.test_name) : ""
67
+ const sameTest = (c: Candidate) => !testName || c.test === testName || c.test.endsWith(`.${testName}`)
68
+ const lineCount = (test: string) => graph.files.find((f) => f.file === file)?.classes.flatMap((k) => k.members.map((m) => ({ k, m }))).find(({ k, m }) => `${k.name}.${m.name}` === test || m.name === test)
69
+ const unmeasured = (): Candidate => {
70
+ const test = testName || path.basename(file).replace(/\.[^.]+$/, "")
71
+ const at = lineCount(test)
72
+ return { test: at ? `${at.k.name}.${at.m.name}` : test, file, line: at?.m.line || 1, lines: at ? at.m.endLine - at.m.line + 1 : undefined, covered: [], missing: measurable, share: 0 }
73
+ }
74
+ const named = file ? cov.candidates.find((c) => c.file === file && sameTest(c)) || unmeasured() : undefined
47
75
  let decision = cov.decision
48
- if (asked) decision = { mode: asked, ask: false, target: named || cov.candidates[0], why: "the user asked for it" }
76
+ if (asked) decision = { mode: asked, ask: false, target: named || cov.candidates[0], why: args.why ? String(args.why) : "the user asked for it" }
77
+ // Not the user's own choice, and no one to ask, or the agent's: ForgeQE's policy settles it.
78
+ // Who decided: the user, ForgeQE's agent, the coverage measure, or ForgeQE's policy when it changed or settled it.
79
+ let by: NonNullable<DecisionRecord["by"]> = asked ? (byAgent ? "agent" : "user") : "coverage"
80
+ if ((!asked || byAgent) && (byAgent || !decision.ask || !ctx.ask)) {
81
+ const settled = settle(decision)
82
+ if (settled.mode !== decision.mode || decision.ask) by = "policy"
83
+ decision = settled
84
+ }
49
85
  const lines = coverageLines(cov)
50
86
  const toTarget = (mode: TestTarget["mode"], c: Candidate): TestTarget => ({
51
87
  mode,
@@ -54,7 +90,7 @@ export async function chooseTarget(runDir: string, repo: string, args: Record<st
54
90
  line: c.line,
55
91
  covered: c.covered.map((i) => cov.items[i]!.label),
56
92
  missing: c.missing.map((i) => cov.items[i]!.label),
57
- why: `it covers ${c.covered.length} of ${c.covered.length + c.missing.length} steps and checks this run located`,
93
+ why: c.covered.length ? `it covers ${c.covered.length} of ${c.covered.length + c.missing.length} steps and checks this run located` : decision.why,
58
94
  })
59
95
  const say = (title: string, why: string) =>
60
96
  ctx.emit?.({ type: "card", card: { status: "info", title, stats: why, body: lines.length ? ["What the repository's tests already cover of this run:", ...lines] : [] } })
@@ -62,7 +98,7 @@ export async function chooseTarget(runDir: string, repo: string, args: Record<st
62
98
  const options = cov.candidates.filter((c) => c.share >= EXTEND_FROM).slice(0, 2).flatMap((c, n) => {
63
99
  const name = `${path.basename(c.file)}`
64
100
  return c.missing.length
65
- ? [{ id: `extend:${n}`, label: `Extend ${name} (adds ${c.missing.length})` }]
101
+ ? [{ id: `extend:${n}`, label: `Extend ${name} (adds ${c.missing.length})` }, ...(n === 0 ? [{ id: `sibling:${n}`, label: `Add a test beside ${c.test.split(".").pop()} in ${name}` }] : [])]
66
102
  : [{ id: `reuse:${n}`, label: `Reuse ${name} as it is` }]
67
103
  })
68
104
  const answer = await ctx.ask({
@@ -72,18 +108,26 @@ export async function chooseTarget(runDir: string, repo: string, args: Record<st
72
108
  options: [...options, { id: "new", label: "Write a new test" }, { id: "cancel", label: "Cancel" }],
73
109
  })
74
110
  if (!answer || answer === "cancel") return { cancelled: true }
75
- if (answer === "new") return {}
76
- const [mode, n] = answer.split(":") as ["reuse" | "extend", string]
111
+ if (answer === "new") {
112
+ note("user", "new", undefined, `${decision.why}; the user chose a new test`)
113
+ say("Writing a new test", "the user chose it")
114
+ return {}
115
+ }
116
+ const [mode, n] = answer.split(":") as ["reuse" | "extend" | "sibling", string]
77
117
  const c = cov.candidates.filter((x) => x.share >= EXTEND_FROM)[Number(n)]!
78
- say(mode === "reuse" ? `Reusing ${c.test}` : `Extending ${c.test}`, "the user chose it")
118
+ note("user", mode, c, `${decision.why}; the user chose it`)
119
+ say(mode === "reuse" ? `Reusing ${c.test}` : mode === "sibling" ? `Adding a test beside ${c.test}` : `Extending ${c.test}`, "the user chose it")
79
120
  return { target: toTarget(mode, c) }
80
121
  }
81
122
  if (decision.mode === "new" || !decision.target) {
123
+ note(by, "new", undefined, decision.why)
82
124
  if (cov.candidates.length) say("Writing a new test", decision.why)
83
125
  return {}
84
126
  }
85
- const mode: TestTarget["mode"] = decision.mode === "reuse" ? "reuse" : decision.mode === "update" ? "update" : "extend"
86
- say(mode === "reuse" ? `Reusing ${decision.target.test}` : mode === "update" ? `Updating ${decision.target.test}` : `Extending ${decision.target.test}`, decision.why)
127
+ const mode: TestTarget["mode"] = decision.mode === "reuse" || decision.mode === "update" || decision.mode === "sibling" ? decision.mode : "extend"
128
+ const verb = { reuse: "Reusing", update: "Updating", sibling: "Adding a test beside", extend: "Extending" }[mode]
129
+ note(by, mode, decision.target, decision.why)
130
+ say(`${verb} ${decision.target.test}`, decision.why)
87
131
  return { target: toTarget(mode, decision.target) }
88
132
  }
89
133
 
@@ -158,11 +202,12 @@ export const COMMAND_TOOLS: McpTool[] = [
158
202
  repo: { type: "string", description: "Repository to write the test in, relative to the working directory (default: the working directory)" },
159
203
  mode: {
160
204
  type: "string",
161
- enum: ["auto", "new", "extend", "reuse", "update"],
205
+ enum: ["auto", "new", "extend", "reuse", "update", "sibling"],
162
206
  description:
163
- "auto (default): ForgeQE measures which existing tests already cover the run, then reuses one that covers it all, extends one that covers most, or writes a new test, and asks the user when that is not clear. new, extend, reuse, update: only when the user said which (\"extend the login test\", \"write a separate test\", \"update it\" after a reused test failed).",
207
+ "auto (default): ForgeQE measures which existing tests already cover the run, then reuses one that covers it all, extends one that covers most (or adds a test beside it, in its class, when it is already long), or writes a new test that reuses the repository's code, and asks the user when that is not clear. new, extend, reuse, update, sibling: only when the user said which (\"extend the login test\", \"add a separate test next to it\", \"write a separate test\", \"update it\" after a reused test failed).",
164
208
  },
165
- test_file: { type: "string", description: "With mode extend, reuse, or update: the existing test file (from the user, or from an earlier write_test result)" },
209
+ test_file: { type: "string", description: "With mode extend, reuse, update, or sibling: the existing test file (from the user, or from an earlier write_test result)" },
210
+ test_name: { type: "string", description: "With test_file: the test in it (Class.Method or the method name), when the file has more than one" },
166
211
  },
167
212
  },
168
213
  async run(args, ctx) {
@@ -177,8 +222,16 @@ export const COMMAND_TOOLS: McpTool[] = [
177
222
  const goal = (readJson(path.join(runDir, "run.json")) as { goal?: string } | null)?.goal || ""
178
223
  // browser_run found an earlier test for this flow and it failed: Scribe updates it rather than adding another.
179
224
  const existingTest = readJson(path.join(runDir, EXISTING_TEST_FILE)) as ExistingTest | null
225
+ // ForgeQE's agent chose, before the browser ran, to extend an earlier test: that test, unless the user says otherwise.
226
+ const extend = readJson(path.join(runDir, EXTEND_TEST_FILE)) as { testFile?: string; test?: string; mode?: "extend" | "sibling" } | null
227
+ // The agent repeating that choice to write_test (its mode and file) is still the agent's choice, not the user's.
228
+ const sameChoice = extend?.testFile && args.mode === (extend.mode || "extend") && (!args.test_file || String(args.test_file).replace(/^\.\//, "") === extend.testFile)
229
+ const how =
230
+ extend?.testFile && (!args.mode || args.mode === "auto" || sameChoice)
231
+ ? { ...args, mode: extend.mode || "extend", test_file: extend.testFile, ...(extend.test ? { test_name: extend.test } : {}), why: "chosen before the browser run, from the existing tests closest to this flow" }
232
+ : args
180
233
  // Otherwise: what the repository's tests already cover of this run decides reuse, extend, or new.
181
- const chosen = existingTest?.testFile ? { target: undefined } : await chooseTarget(runDir, repo, args, ctx)
234
+ const chosen = existingTest?.testFile ? { target: undefined } : await chooseTarget(runDir, repo, how, ctx)
182
235
  if ("cancelled" in chosen) return { status: "cancelled", notes: "The user cancelled before Scribe started; nothing was changed." }
183
236
  const target = chosen.target
184
237
  ctx.emit?.({ type: "code-start", runDir, repo, goal })
@@ -200,6 +253,7 @@ export const COMMAND_TOOLS: McpTool[] = [
200
253
  throw err
201
254
  }
202
255
  ctx.emit?.({ type: "code-done", result: r, ms: Date.now() - t0 })
256
+ recordDecision(repo, { goal, stage: "outcome", where: "write_test", status: r.status, ...(r.existing ? { existing: r.existing } : {}), ...(r.testFile ? { file: r.testFile } : {}), ...(target ? { mode: target.mode, test: target.test } : existingTest ? { mode: "update" } : { mode: "new" }), ...(r.breaches ? { breaches: r.breaches } : {}), run_dir: runDir })
203
257
  ctx.emit?.({ type: "card", card: codeCard(buildCodeSummary(r, Date.now() - t0)) })
204
258
  return {
205
259
  status: r.status,
@@ -0,0 +1,187 @@
1
+ import { realpathSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import { listPacks, loadPack, makePack, packCard, packMarkdown, packRunCard, runPack, type PackSources } from "../pack/index.ts"
5
+ import { packsDir } from "../pack/pack.ts"
6
+ import { runPackOnPipeline } from "../pack/pipeline.ts"
7
+ import type { McpTool, ToolContext } from "./tools.ts"
8
+
9
+ const clip = (s: string | undefined, n: number) => {
10
+ const t = (s || "").trim()
11
+ return t.length <= n ? t : `…${t.slice(-(n - 1))}`
12
+ }
13
+
14
+ function repoOf(ctx: ToolContext, given?: unknown): string {
15
+ const p = given ? path.resolve(ctx.cwd, String(given)) : ctx.cwd
16
+ try {
17
+ return realpathSync(p)
18
+ } catch {
19
+ return p
20
+ }
21
+ }
22
+
23
+ const ITEM_SCHEMA = {
24
+ type: "object",
25
+ properties: {
26
+ source: { type: "string", description: "The tool it came from: jira, github, azure_devops, confluence, linear…" },
27
+ id: { type: "string" },
28
+ title: { type: "string" },
29
+ text: { type: "string", description: "What changes, in its words: description, acceptance criteria, release note" },
30
+ url: { type: "string" },
31
+ type: { type: "string", description: "Story, Bug, Feature, Task…" },
32
+ kind: { type: "string", enum: ["work_item", "pr", "commit", "note"] },
33
+ files: { type: "array", items: { type: "string" }, description: "Files it changed, when known" },
34
+ links: { type: "array", items: { type: "string" }, description: "Work items it refers to: ado:123, jira:PAY-12, github:45" },
35
+ test_cases: { type: "array", items: { type: "string" }, description: "Test cases that test it (Azure DevOps ids, or ADO-123)" },
36
+ },
37
+ required: ["title"],
38
+ }
39
+
40
+ export const PACK_TOOLS: McpTool[] = [
41
+ {
42
+ name: "build_regression_pack",
43
+ description:
44
+ "Build a regression pack: the repository's tests that a release's changes can break, each with why it is in (it automates a test case of a changed work item; the change is in the test code it uses; the work item's words match what it does; it works in a risk area the release touches; it is a smoke test), and the changes no test covers, each with the closest test and what it lacks. Changes come from any mix of: an Azure DevOps sprint, area, epic, or work item ids (with their linked pull requests' files); the commits between two refs of a repository; pull requests by number or merged in the last days; and items you read from any other connected tool (Jira, GitHub issues, a release note), passed in items. Saves the pack by name in .forgeqe/packs and shows a card. Nothing runs. Takes seconds.",
45
+ inputSchema: {
46
+ type: "object",
47
+ properties: {
48
+ name: { type: "string", description: "The pack's name, e.g. release-1.5 (default: release-<date>)" },
49
+ repo: { type: "string", description: "The test repository (default: the working directory)" },
50
+ sprint: { type: "string", description: "Azure DevOps sprint name of the team, or current" },
51
+ iteration: { type: "string", description: "Azure DevOps iteration path and everything under it: a release such as Release 2026.09, or Project\\Release 2026.09\\Sprint 13" },
52
+ area: { type: "string", description: "Azure DevOps area path" },
53
+ parent: { type: "number", description: "An Azure DevOps epic or feature: everything under it" },
54
+ work_items: { type: "array", items: { type: "number" }, description: "Azure DevOps work item ids" },
55
+ project: { type: "string", description: "Azure DevOps project (default: sources.azure_devops.project)" },
56
+ range_repo: { type: "string", description: "Repository for from/to (the product's, or the tests' own); default: the test repository" },
57
+ from: { type: "string", description: "Commits after this ref: a tag, branch, or commit (e.g. release/1.4)" },
58
+ to: { type: "string", description: "Commits up to this ref (default HEAD)" },
59
+ prs: { type: "array", items: { type: "string" }, description: "Pull request numbers or URLs" },
60
+ merged_days: { type: "number", description: "Pull requests merged in the last N days" },
61
+ pr_repo: { type: "string", description: "Repository the pull requests are in (default: the test repository)" },
62
+ items: { type: "array", items: ITEM_SCHEMA, description: "Changes you read from other connected tools, as they are" },
63
+ risk: { type: "boolean", description: "Add tests for risk areas the release touches (default true)" },
64
+ smoke: { type: "boolean", description: "Add smoke-tagged tests (default true)" },
65
+ },
66
+ },
67
+ async run(args, ctx) {
68
+ const repo = repoOf(ctx, args.repo)
69
+ const sources: PackSources = {
70
+ ...(args.sprint || args.iteration || args.area || args.parent || (Array.isArray(args.work_items) && args.work_items.length)
71
+ ? { ado: { ...(args.sprint ? { sprint: String(args.sprint) } : {}), ...(args.iteration ? { iteration: String(args.iteration) } : {}), ...(args.area ? { area: String(args.area) } : {}), ...(args.parent ? { parent: Number(args.parent) } : {}), ...(Array.isArray(args.work_items) ? { ids: args.work_items.map(Number).filter(Boolean) } : {}), ...(args.project ? { project: String(args.project) } : {}) } }
72
+ : {}),
73
+ ...(args.from ? { range: { repo: repoOf(ctx, args.range_repo || args.repo), from: String(args.from), ...(args.to ? { to: String(args.to) } : {}) } } : {}),
74
+ ...(Array.isArray(args.prs) && args.prs.length ? { prs: args.prs.map(String) } : {}),
75
+ ...(Number(args.merged_days) > 0 ? { mergedDays: Number(args.merged_days) } : {}),
76
+ ...(args.pr_repo ? { prRepo: repoOf(ctx, args.pr_repo) } : {}),
77
+ ...(Array.isArray(args.items) && args.items.length ? { items: args.items } : {}),
78
+ }
79
+ const pack = await makePack({ config: ctx.config, repo, name: args.name ? String(args.name) : undefined, sources, risk: args.risk !== false, smoke: args.smoke !== false })
80
+ const md = path.join(packsDir(repo), `${pack.name}.md`)
81
+ writeFileSync(md, `${packMarkdown(pack)}\n`)
82
+ ctx.emit?.({ type: "card", card: packCard(pack) })
83
+ return {
84
+ name: pack.name,
85
+ tests: pack.tests.map((t) => ({ test: t.test, file: t.file, why: [...new Set(t.reasons.map((r) => r.text))], ...(t.flaky ? { flaky: t.flaky } : {}) })),
86
+ changes: pack.items.length,
87
+ from: pack.scope,
88
+ not_covered: pack.uncovered,
89
+ risk_areas: pack.risk,
90
+ notes: pack.notes.length ? pack.notes : undefined,
91
+ file: md,
92
+ next_step: [
93
+ pack.tests.length ? `Offer to run it: run_regression_pack with name ${pack.name} and on local, testmu, or pipeline.` : "",
94
+ pack.uncovered.length ? "Name the changes no test covers. For one with a closest test, offer to extend that test (browser_run with existing_test and existing_mode extend), so the next pack covers it." : "",
95
+ ]
96
+ .filter(Boolean)
97
+ .join(" "),
98
+ }
99
+ },
100
+ },
101
+ {
102
+ name: "run_regression_pack",
103
+ description:
104
+ "Run a saved regression pack after the user confirms: on local (this machine's browsers), testmu (TestMu AI's cloud browsers: the repository's tests connect to them through FORGEQE_PW_WS_ENDPOINT), or pipeline (a CI pipeline runs it). Reads each test's result, keeps the run with the pack, and says what changed since its last run. Where ForgeQE cannot ask (an MCP client), pass confirmed: true only after the user agreed. Takes minutes.",
105
+ inputSchema: {
106
+ type: "object",
107
+ properties: {
108
+ name: { type: "string", description: "The pack's name" },
109
+ on: { type: "string", enum: ["local", "testmu", "pipeline"], description: "Where to run it (default local)" },
110
+ pipeline: { type: "string", enum: ["github", "azure_devops"], description: "With on pipeline: which one (default: packs.pipeline.default in forgeqe.yaml)" },
111
+ browsers: { type: "string", enum: ["local", "testmu"], description: "With on pipeline: the runner's own browser, or TestMu AI's (default local)" },
112
+ repo: { type: "string", description: "The test repository (default: the working directory)" },
113
+ confirmed: { type: "boolean", description: "Only where ForgeQE cannot ask: the user agreed to this run" },
114
+ },
115
+ required: ["name"],
116
+ },
117
+ async run(args, ctx) {
118
+ const repo = repoOf(ctx, args.repo)
119
+ const pack = loadPack(repo, String(args.name))
120
+ const on = args.on === "testmu" || args.on === "pipeline" ? args.on : "local"
121
+ const browsers = args.browsers === "testmu" ? "testmu" : "local"
122
+ const where = on === "testmu" ? "on TestMu AI" : on === "pipeline" ? `in the ${args.pipeline === "azure_devops" ? "Azure DevOps" : args.pipeline === "github" ? "GitHub Actions" : "configured"} pipeline${browsers === "testmu" ? " on TestMu AI" : ""}` : "on this machine"
123
+ const what = `${pack.tests.length} test${pack.tests.length === 1 ? "" : "s"}`
124
+ if (ctx.ask) {
125
+ const choice = await ctx.ask({
126
+ title: `Run pack ${pack.name} (${what}) ${where}?`,
127
+ lines: pack.tests.slice(0, 20).map((t) => ` • ${t.test}`).concat(pack.tests.length > 20 ? [` and ${pack.tests.length - 20} more`] : []),
128
+ options: [
129
+ { id: "run", label: `Run ${what} ${where}` },
130
+ { id: "no", label: "Don't run" },
131
+ ],
132
+ })
133
+ if (choice !== "run") return { ran: false, reason: "The user chose not to run it." }
134
+ } else if (args.confirmed !== true) {
135
+ return { ran: false, needs_confirmation: true, tests: pack.tests.map((t) => t.test), on, next_step: "Show the user the pack and where it runs; call run_regression_pack again with confirmed: true only after they say yes." }
136
+ }
137
+ const { run, record, notes, pack: after } =
138
+ on === "pipeline"
139
+ ? await runPackOnPipeline({
140
+ config: ctx.config,
141
+ repo,
142
+ name: pack.name,
143
+ ...(args.pipeline === "github" || args.pipeline === "azure_devops" ? { kind: args.pipeline } : {}),
144
+ target: browsers,
145
+ signal: ctx.signal,
146
+ onProgress: (text) => ctx.emit?.({ type: "agent", event: { type: "code", kind: "info", text } }),
147
+ })
148
+ : await runPack({ config: ctx.config, repo, name: pack.name, on, signal: ctx.signal, runTest: ctx.runTest })
149
+ ctx.emit?.({ type: "card", card: packRunCard(after, run, record, notes) })
150
+ const failed = run.tests.filter((t) => t.status === "failed")
151
+ return {
152
+ ran: true,
153
+ on: record.target,
154
+ status: run.status,
155
+ passed: run.counts.passed,
156
+ failed: run.counts.failed,
157
+ skipped: run.counts.skipped,
158
+ seconds: run.seconds,
159
+ failures: failed.length ? failed.map((t) => ({ name: t.name, message: t.message })) : undefined,
160
+ ...(run.flaky?.length ? { flaky: run.flaky } : {}),
161
+ previous: after.runs.at(-2) || undefined,
162
+ output: run.status === "passed" ? undefined : clip(run.output, 3000),
163
+ folder: run.dir,
164
+ ...(record.url ? { pipeline_run: record.url } : {}),
165
+ notes: notes.length ? notes : undefined,
166
+ next_step: failed.length ? "Name the failing tests with their messages; offer heal_tests for a test the app outgrew, or file the defects (file_bug)." : undefined,
167
+ }
168
+ },
169
+ },
170
+ {
171
+ name: "list_regression_packs",
172
+ description: "The saved regression packs of a repository: name, when built, from what, how many tests and gaps, and their last run. Nothing runs.",
173
+ inputSchema: { type: "object", properties: { repo: { type: "string" } } },
174
+ async run(args, ctx) {
175
+ return {
176
+ packs: listPacks(repoOf(ctx, args.repo)).map((p) => ({
177
+ name: p.name,
178
+ built: p.createdAt.slice(0, 16).replace("T", " "),
179
+ from: p.scope,
180
+ tests: p.tests.length,
181
+ not_covered: p.uncovered.length,
182
+ last_run: p.runs.at(-1) ? { at: p.runs.at(-1)!.at.slice(0, 16).replace("T", " "), on: p.runs.at(-1)!.target, status: p.runs.at(-1)!.status, failed: p.runs.at(-1)!.counts.failed } : undefined,
183
+ })),
184
+ }
185
+ },
186
+ },
187
+ ]