@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
package/src/mcp/tools.ts CHANGED
@@ -11,14 +11,18 @@ import { createAdoDashboard, type DashboardWidget } from "../cases/dashboards.ts
11
11
  import { writeAdoWikiPage } from "../cases/wiki.ts"
12
12
  import { readConfluence } from "../cases/confluence.ts"
13
13
  import { casesFrom, type CaseSelector, type TestCase } from "../cases/index.ts"
14
- import { saveAdoCases, saveJiraCases, type NewCase } from "../cases/save.ts"
14
+ import { saveAdoCases, saveJiraCases, type NewCase, type SaveStatus } from "../cases/save.ts"
15
15
  import { buildHandoff } from "../code/handoff.ts"
16
16
  import { latestRunDir, runCodeAgent } from "../code/index.ts"
17
17
  import type { OpencodeSession } from "../code/runtime.ts"
18
18
  import { runTestCommand } from "../code/testrun.ts"
19
- import { EXISTING_TEST_FILE, findWrittenTest } from "../code/notes.ts"
19
+ import { EXISTING_TEST_FILE, EXTEND_TEST_FILE, findRelatedTests, findWrittenTest, sameFlow, writtenTestFor, writtenTests } from "../code/notes.ts"
20
+ import { buildGraph } from "../code/graph/graph.ts"
21
+ import { walk } from "../code/profile.ts"
22
+ import { indexTests, oneTestCommand, shortlistTests } from "../code/shortlist.ts"
23
+ import { recordDecision } from "../code/decisions.ts"
20
24
  import { browserProvider, type AppConfig } from "../config.ts"
21
- import { clearMemory, memoryFilesFor, memoryFlows, memoryOverview, memoryShow } from "../memory.ts"
25
+ import { clearMemory, memoryFilesFor, memoryFlows, memoryOverview, memoryShow, readPages } from "../memory.ts"
22
26
  import { executeRun, runDirectory } from "../run.ts"
23
27
  import { counts } from "../suite/report.ts"
24
28
  import { deviceCases, runSuite, suiteParallel, type SuiteEvent, type SuiteResult } from "../suite/run.ts"
@@ -27,6 +31,7 @@ import { PR_TOOLS } from "./prtools.ts"
27
31
  import { CASE_TOOLS, checkResult, precheck } from "./casetools.ts"
28
32
  import { REPO_TEST_TOOLS } from "./repotests.ts"
29
33
  import { DEFECT_TOOLS } from "./defecttools.ts"
34
+ import { PACK_TOOLS } from "./packtools.ts"
30
35
  import { API_TOOLS } from "./apitools.ts"
31
36
  import { EXPLORE_TOOLS } from "./exploretools.ts"
32
37
  import { QUALITY_TOOLS } from "./qualitytools.ts"
@@ -330,21 +335,42 @@ function devicesFor(args: Record<string, unknown>, ctx: ToolContext): string[] {
330
335
  return [...new Set(devices)]
331
336
  }
332
337
 
338
+ type ExistingFirst = { passed?: Record<string, unknown>; failed?: { testFile: string; command: string; output: string; why: string } }
339
+
340
+ function repoOf(ctx: ToolContext): string {
341
+ try {
342
+ return realpathSync(ctx.cwd)
343
+ } catch {
344
+ return ctx.cwd
345
+ }
346
+ }
347
+
333
348
  /**
334
349
  * Runs the test an earlier job wrote for this goal, if there is one. `passed`: the tool's answer, the test having
335
350
  * passed in a browser. `failed`: the test to update, for write_test. Null when there is no such test.
336
351
  */
337
- async function existingTestFirst(goal: string, runDir: string, ctx: ToolContext): Promise<{ passed?: Record<string, unknown>; failed?: { testFile: string; command: string; output: string; why: string } } | null> {
352
+ async function existingTestFirst(goal: string, runDir: string, ctx: ToolContext): Promise<ExistingFirst | null> {
338
353
  const cfg = ctx.config
339
354
  if (!cfg.memory.enabled) return null
340
- let repo = ctx.cwd
341
- try {
342
- repo = realpathSync(ctx.cwd)
343
- } catch {}
355
+ const repo = repoOf(ctx)
344
356
  const known = findWrittenTest(cfg.memory.dir, repo, goal, (f) => existsSync(path.resolve(repo, f)))
345
357
  if (!known) return null
358
+ ctx.emit?.({ type: "info", text: `This flow already has a test, ${known.test_file} (written ${known.at.slice(0, 10)}): ForgeQE runs it first` })
359
+ recordDecision(repo, { goal, stage: "chosen", where: "browser_run", by: "history", mode: "reuse", file: known.test_file, why: `an earlier job wrote it for the same flow (${known.at.slice(0, 10)})` })
360
+ return runKnownTest(known, repo, runDir, ctx, goal)
361
+ }
362
+
363
+ /** A test to run as it is: one an earlier job wrote (with the goal it was written for), or one found in the repository. */
364
+ type RunnableTest = { test_file: string; command: string; test?: string; goal?: string; at?: string; derived?: boolean }
365
+
366
+ /**
367
+ * Runs an existing test as it is: passing in a browser answers the goal; failing, Scribe updates it after the run.
368
+ * A command ForgeQE built itself (derived) that opens no browser ran nothing: the browser runs, and nothing is updated.
369
+ */
370
+ async function runKnownTest(known: RunnableTest, repo: string, runDir: string, ctx: ToolContext, goal: string, asCheck = false): Promise<ExistingFirst | null> {
371
+ const outcome = (status: string) => recordDecision(repo, { goal, stage: "outcome", where: "browser_run", mode: "reuse", file: known.test_file, ...(known.test ? { test: known.test } : {}), status, run_dir: runDir })
372
+ const cfg = ctx.config
346
373
  const emit = ctx.emit || (() => {})
347
- emit({ type: "info", text: `This flow already has a test, ${known.test_file} (written ${known.at.slice(0, 10)}): ForgeQE runs it first` })
348
374
  mkdirSync(runDir, { recursive: true })
349
375
  const run = await (ctx.runTest || runTestCommand)({
350
376
  command: known.command,
@@ -356,33 +382,151 @@ async function existingTestFirst(goal: string, runDir: string, ctx: ToolContext)
356
382
  })
357
383
  if (ctx.signal?.aborted) return null
358
384
  const secs = Math.round(run.ms / 1000)
385
+ const name = known.test ? `${known.test} (${known.test_file})` : known.test_file
359
386
  if (run.ok && run.browsers.length) {
387
+ outcome("passed")
360
388
  emit({
361
389
  type: "card",
362
390
  card: {
363
391
  status: "ok",
364
392
  title: "Existing test passed",
365
393
  stats: `${secs}s · no browser run or new test needed`,
366
- body: [`Test ${known.test_file}`, `Command ${known.command}`, `Browser ${run.browsers.join(", ")}`, `Written ${known.at.slice(0, 10)} for the same flow`],
394
+ body: [`Test ${name}`, `Command ${known.command}`, `Browser ${run.browsers.join(", ")}`, ...(known.at ? [`Written ${known.at.slice(0, 10)} by an earlier job`] : [])],
367
395
  },
368
396
  })
369
397
  return {
370
398
  passed: {
371
399
  status: "passed",
372
400
  reused_test: known.test_file,
373
- written_for: known.goal,
401
+ ...(known.test ? { test: known.test } : {}),
402
+ ...(known.goal ? { written_for: known.goal } : {}),
374
403
  command: known.command,
375
404
  browsers: run.browsers,
376
405
  seconds: secs,
377
- note: "An earlier job wrote a test for this flow (written_for) and it passed as it is, in a real browser: no browser run and no new test were needed. If the user's request asks for something written_for does not check, call browser_run again with fresh: true; otherwise say in one or two lines that the existing test passed.",
406
+ note: asCheck
407
+ ? "The user's check is answered by this existing test: it does what the check asks and passed just now in a real browser, so no browser run was needed. Report that it passed, naming the test and the command; nothing is written for a check."
408
+ : `An existing test${known.goal ? " written for written_for" : ""} passed as it is, in a real browser: no browser run and no new test were needed. If the user's request asks for something that test does not do or check, call browser_run again with existing_test set to ${known.test ? "test" : "reused_test"} and existing_mode extend or sibling (the browser runs, and that test is extended, or a test is added beside it, rather than a new one written); otherwise say in one or two lines that the existing test passed.`,
378
409
  },
379
410
  }
380
411
  }
412
+ if (run.ok && known.derived) {
413
+ outcome("ran nothing")
414
+ emit({ type: "info", text: `${known.command} ran no browser test: ForgeQE could not run ${name} on its own, so the browser runs the flow` })
415
+ return null
416
+ }
417
+ outcome(run.ok ? "opened no browser" : "failed")
381
418
  const why = run.ok ? "It exits 0 but opened no browser." : `It fails now (${run.exitCode === null ? "timed out" : `exit ${run.exitCode}`}): the application or the page changed since it was written.`
382
- emit({ type: "info", text: `${known.test_file} ${run.ok ? "opened no browser" : "fails now"}: ForgeQE runs the flow in the browser, and Scribe updates that test instead of writing a new one` })
419
+ emit({ type: "info", text: asCheck ? `${name} ${run.ok ? "opened no browser" : "fails now"}: ForgeQE runs the check in the browser` : `${name} ${run.ok ? "opened no browser" : "fails now"}: ForgeQE runs the flow in the browser, and Scribe updates that test instead of writing a new one` })
383
420
  return { failed: { testFile: known.test_file, command: known.command, output: run.output, why } }
384
421
  }
385
422
 
423
+ type ChosenMode = "reuse" | "extend" | "sibling"
424
+ type Chosen = { mode: ChosenMode; file: string; test?: string; runnable?: RunnableTest; repo: string }
425
+
426
+ /**
427
+ * The existing test ForgeQE's agent chose: a test id from existing_tests_found (Class.Method) or a file. Reusing it
428
+ * needs a command: the one an earlier job ran it with, else one built the way the repository runs a single test.
429
+ */
430
+ async function chosenTest(ref: string, mode: ChosenMode, ctx: ToolContext): Promise<Chosen> {
431
+ const repo = repoOf(ctx)
432
+ const graph = await buildGraph(repo).catch(() => null)
433
+ const tests = graph ? indexTests(graph).map((t) => t.entry) : []
434
+ const clean = ref.replace(/^\.\//, "")
435
+ const byId = tests.filter((t) => t.test === clean || t.test.endsWith(`.${clean}`))
436
+ const file = byId[0]?.file || clean
437
+ if (!existsSync(path.resolve(repo, file))) throw new Error(`existing_test ${ref} is not a test in ${repo}. Pass a test or test_file from existing_tests_found, or fresh: true.`)
438
+ const inFile = tests.filter((t) => t.file === file)
439
+ const test = byId[0]?.test || (inFile.length === 1 ? inFile[0]!.test : undefined)
440
+ if (mode !== "reuse") return { mode, file, ...(test ? { test } : {}), repo }
441
+ const known = ctx.config.memory.enabled ? writtenTestFor(ctx.config.memory.dir, repo, file) : null
442
+ // An earlier job's command runs the test it wrote; for another test in that file, a command is built.
443
+ const method = test?.split(".").pop() || ""
444
+ if (known?.command && (!test || inFile.length <= 1 || known.command.includes(method))) return { mode, file, ...(test ? { test } : {}), repo, runnable: { ...known, ...(test ? { test } : {}) } }
445
+ if (!test && inFile.length > 1) throw new Error(`${file} has ${inFile.length} tests: pass the one to reuse as existing_test (Class.Method, from existing_tests_found).`)
446
+ const knownCommands = ctx.config.memory.enabled ? writtenTests(ctx.config.memory.dir, repo).map((t) => t.command) : []
447
+ const command = test ? oneTestCommand(repo, { test, file }, knownCommands, walk(repo)) : null
448
+ if (!command || !test) throw new Error(`ForgeQE cannot tell how to run ${test || file} on its own: call browser_run with existing_mode extend or sibling to work from it, or write_test with mode reuse after the run.`)
449
+ return { mode, file, test, repo, runnable: { test_file: file, test, command, derived: true } }
450
+ }
451
+
452
+ /** Goals the existing-tests check already stopped once in this process: asked again, the browser runs. */
453
+ const gated = new Set<string>()
454
+ /** Existing tests ForgeQE's agent has been shown: fresh: true skips the check only for tests it has seen. */
455
+ const shown = new Set<string>()
456
+
457
+ /**
458
+ * Before the browser explores anything: the repository's tests closest to this goal, ranked from the code graph and
459
+ * page memory without a model (shortlistTests), with the tests earlier jobs wrote for close goals. ForgeQE's agent
460
+ * compares them with the request and picks: reuse one, extend one, add a test beside one, or run fresh for a new test
461
+ * that reuses the repository's code. Null when there are none, or this goal was already stopped once.
462
+ */
463
+ /** A request sent as a task is shown the repository's tests only when they match it this closely. */
464
+ const TASK_FROM = 0.4
465
+
466
+ async function existingTestsFirst(goal: string, url: string | undefined, fresh: boolean, ctx: ToolContext, asTask = false): Promise<Record<string, unknown> | null> {
467
+ const cfg = ctx.config
468
+ if (!cfg.memory.enabled) return null
469
+ const repo = repoOf(ctx)
470
+ const key = `${repo}\n${goal.replace(/\s+/g, " ").trim().toLowerCase()}`
471
+ if (gated.has(key)) {
472
+ // Shown already: the browser runs, which is the agent's choice, made or not.
473
+ recordDecision(repo, { goal, stage: "chosen", where: "browser_run", by: "agent", mode: "fresh", why: fresh ? (asTask ? "a task, not a test" : "none of the tests shown is this journey") : "asked again without choosing" })
474
+ return null
475
+ }
476
+ const exists = (f: string) => existsSync(path.resolve(repo, f))
477
+ // fresh: true asks for a new run of a flow that has a test; for tests only close to it, before the agent has seen
478
+ // them, it is a guess, not a decision: it sees them first.
479
+ const written = findRelatedTests(cfg.memory.dir, repo, goal, exists).filter((t) => !fresh || !sameFlow(t.goal, goal))
480
+ const graph = await buildGraph(repo).catch(() => null)
481
+ const list = graph ? shortlistTests({ graph, pages: readPages(cfg.memory.dir), request: goal, url }) : null
482
+ type Shown = { test?: string; test_file: string; line?: number; lines?: number; tests_in_class?: number; tags?: string[]; steps?: string[]; covers?: string[]; lacks?: string[]; why?: string[]; score?: number; written_for?: string; command?: string; written?: string }
483
+ const out: Shown[] = (list?.candidates || []).filter((c) => !asTask || c.score >= TASK_FROM).map((c) => ({ test: c.test, test_file: c.file, line: c.line, lines: c.lines, tests_in_class: c.siblings + 1, tags: c.tags, steps: c.steps, covers: c.covers, lacks: c.lacks, why: c.why, score: c.score }))
484
+ // A test an earlier job wrote: its goal and command on the entry for its file, or an entry of its own.
485
+ for (const w of asTask ? written.filter((t) => t.similarity >= TASK_FROM) : written) {
486
+ const same = out.find((o) => o.test_file === w.test_file)
487
+ if (same) Object.assign(same, { written_for: w.goal, command: w.command, written: w.at.slice(0, 10) })
488
+ else out.push({ test_file: w.test_file, written_for: w.goal, command: w.command, written: w.at.slice(0, 10), score: Number(w.similarity.toFixed(2)) })
489
+ }
490
+ const keyOf = (o: Shown) => `${repo}\n${o.test_file}#${o.test || ""}`
491
+ if (!out.length) return null
492
+ if (fresh && out.every((o) => shown.has(keyOf(o)))) {
493
+ recordDecision(repo, { goal, stage: "chosen", where: "browser_run", by: "agent", mode: "fresh", why: asTask ? "a task, not a test" : "none of the tests shown is this journey" })
494
+ return null
495
+ }
496
+ gated.add(key)
497
+ recordDecision(repo, { goal, stage: "shown", where: "browser_run", ...(asTask ? { why: "sent as a task" } : {}), shown: out.map((o) => ({ ...(o.test ? { test: o.test } : {}), file: o.test_file, ...(o.score !== undefined ? { score: o.score } : {}), ...(o.covers ? { covers: o.covers, lacks: o.lacks } : {}) })) })
498
+ for (const o of out) shown.add(keyOf(o))
499
+ const body = out.map((o) => {
500
+ const what = o.covers?.length || o.lacks?.length ? ` covers ${o.covers!.length} of ${o.covers!.length + o.lacks!.length}${o.lacks!.length ? `; lacks ${o.lacks!.slice(0, 3).join(", ")}${o.lacks!.length > 3 ? ` +${o.lacks!.length - 3}` : ""}` : ""}` : ""
501
+ return `${o.test || o.test_file}${o.line ? ` (${o.test_file}:${o.line}, ${o.lines} lines)` : ""}${what}${o.written_for ? ` · written for "${o.written_for.replace(/\s+/g, " ").slice(0, 80)}"` : ""}`
502
+ })
503
+ ctx.emit?.({
504
+ type: "card",
505
+ card: {
506
+ status: "info",
507
+ title: `${out.length === 1 ? "An existing test is" : `${out.length} existing tests are`} close to this flow`,
508
+ stats: `no browser run yet: reuse, extend, or build on them before exploring${list ? ` · ${list.tests} tests searched in ${list.ms}ms` : ""}`,
509
+ body: [...body, ...(list?.unlocated.length ? [`not in any page object yet: ${list.unlocated.join(", ")}`] : [])],
510
+ },
511
+ })
512
+ return {
513
+ status: "existing_tests_found",
514
+ browser: "not started",
515
+ searched: list ? `${list.tests} tests, ranked from the code graph and page memory` : "the tests earlier jobs wrote",
516
+ ...(list?.elements.length ? { request_elements: list.elements } : {}),
517
+ ...(list?.unlocated.length ? { not_in_page_objects: list.unlocated } : {}),
518
+ existing_tests: out,
519
+ ...(asTask
520
+ ? {
521
+ sent_as: "task",
522
+ note: "This was sent as a task, but the repository has tests close to it. When one of them does what the request asks, answer the check with it: call browser_run again with the same goal and purpose, existing_test set to that test, and existing_mode reuse. ForgeQE runs it, and the browser runs only when it fails; nothing is written. When the user asked for a test, call browser_run with purpose test and one of the choices in next_step. Only when no test does what it asks, or it needs data a passing test does not give (a value to read or report), call it again with fresh: true.",
523
+ }
524
+ : {}),
525
+ next_step:
526
+ "Before any browser run, compare each test's steps, covers, lacks, and written_for with the user's request, then call browser_run again with the same goal and one of these: existing_test (its test, or test_file) with existing_mode reuse, when it already does and checks everything the request asks (ForgeQE runs it; no browser run when it passes); existing_mode extend, when it covers the start of the journey and the request adds a few steps or checks to it; existing_mode sibling, when the request is a variant of it (another path, other data, or more than a few added steps, or the test is long already): a new test beside it in its class, reusing its setup and steps; or fresh: true, when none of them is this journey: a new test that still reuses the repository's page objects and helpers.",
527
+ }
528
+ }
529
+
386
530
  export const TOOLS: McpTool[] = [
387
531
  {
388
532
  name: "browser_run",
@@ -419,7 +563,17 @@ export const TOOLS: McpTool[] = [
419
563
  fresh: {
420
564
  type: "boolean",
421
565
  description:
422
- "true: run in the browser even when an earlier job wrote a test for this goal. Only when the user asks for a new browser run or a new test; by default an existing test is run first and reused when it passes.",
566
+ "true: run in the browser even when an earlier job wrote a test for this goal or one close to it. Only when the user asks for a new browser run or a new test, or when existing_tests_found listed none that is this journey; by default an existing test is run first and reused when it passes.",
567
+ },
568
+ existing_test: {
569
+ type: "string",
570
+ description: "A test (Class.Method) or test_file from existing_tests_found, or one the user named, to work from, with existing_mode.",
571
+ },
572
+ existing_mode: {
573
+ type: "string",
574
+ enum: ["reuse", "extend", "sibling"],
575
+ description:
576
+ "With existing_test. reuse: the test already does and checks everything the request asks; ForgeQE runs it, and the browser runs only when it fails. extend: the request adds a few steps or checks to that test's journey; the browser runs the goal and write_test extends that test. sibling: the request is a variant of that test; the browser runs the goal and write_test adds a test beside it in its class, reusing its setup and steps.",
423
577
  },
424
578
  },
425
579
  required: ["goal", "purpose"],
@@ -439,8 +593,30 @@ export const TOOLS: McpTool[] = [
439
593
  const emit = ctx.emit || (() => {})
440
594
  // The same flow asked again: an earlier job's test for it runs first. Passing in a browser, it answers the goal
441
595
  // in seconds, with no browser run and no new test; failing, the run goes ahead and Scribe updates that test.
442
- const reuse = ctx.writeTests && args.purpose !== "task" && !args.fresh && !resumeFrom && devices.length <= 1 ? await existingTestFirst(goal, runDir, ctx) : null
596
+ const mayReuse = ctx.writeTests && args.purpose !== "task" && !resumeFrom && devices.length <= 1
597
+ // Sent as a task, but close to the repository's tests: the agent may have taken a test of the app for a lookup.
598
+ // A check of the application, answered by running the existing test that does it: no browser run when it passes.
599
+ const checkByTest = ctx.writeTests && args.purpose === "task" && !resumeFrom && devices.length <= 1
600
+ if (checkByTest && !args.existing_test) {
601
+ const found = await existingTestsFirst(goal, url, Boolean(args.fresh), ctx, true)
602
+ if (found) return found
603
+ }
604
+ const mode: ChosenMode = args.existing_mode === "extend" || args.existing_mode === "sibling" ? args.existing_mode : "reuse"
605
+ if (checkByTest && args.existing_test && mode !== "reuse") throw new Error(`existing_mode ${mode} writes a test: call browser_run with purpose test for it, or existing_mode reuse to answer the check with that test.`)
606
+ const chosen = (mayReuse || checkByTest) && args.existing_test ? await chosenTest(String(args.existing_test), mode, ctx) : null
607
+ let reuse: ExistingFirst | null = null
608
+ if (chosen) recordDecision(chosen.repo, { goal, stage: "chosen", where: "browser_run", by: "agent", mode: chosen.mode, file: chosen.file, ...(chosen.test ? { test: chosen.test } : {}) })
609
+ if (chosen?.runnable) reuse = await runKnownTest(chosen.runnable, chosen.repo, runDir, ctx, goal, checkByTest)
610
+ else if (mayReuse && !chosen) {
611
+ if (!args.fresh) reuse = await existingTestFirst(goal, runDir, ctx)
612
+ // Not the same flow, but close to existing tests: decide reuse, extend, or build on one before the browser explores.
613
+ if (!reuse) {
614
+ const found = await existingTestsFirst(goal, url, Boolean(args.fresh), ctx)
615
+ if (found) return found
616
+ }
617
+ }
443
618
  if (reuse?.passed) return reuse.passed
619
+ const extend = chosen && chosen.mode !== "reuse" ? chosen : undefined
444
620
  const t0 = Date.now()
445
621
  emit({ type: "run-start", goal, url })
446
622
  try {
@@ -470,10 +646,15 @@ export const TOOLS: McpTool[] = [
470
646
  }
471
647
  if (ctx.writeTests && result.status === "complete" && result.driver !== "synthetic") {
472
648
  if (reuse?.failed) writeFileSync(path.join(runDir, EXISTING_TEST_FILE), `${JSON.stringify(reuse.failed, null, 2)}\n`)
473
- return {
474
- ...summary,
475
- next_step: `This session turns passed runs into tests: call write_test with run_dir ${runDir} now, before you reply.${reuse?.failed ? ` It updates the existing test ${reuse.failed.testFile} instead of writing a new one.` : ""}`,
476
- }
649
+ else if (extend) writeFileSync(path.join(runDir, EXTEND_TEST_FILE), `${JSON.stringify({ testFile: extend.file, ...(extend.test ? { test: extend.test } : {}), mode: extend.mode }, null, 2)}\n`)
650
+ const how = reuse?.failed
651
+ ? ` It updates the existing test ${reuse.failed.testFile} instead of writing a new one.`
652
+ : extend?.mode === "sibling"
653
+ ? ` It adds a test beside ${extend.test || "the existing test"} in ${extend.file}, reusing its setup and steps, instead of writing a new file.`
654
+ : extend
655
+ ? ` It extends the existing test ${extend.file} instead of writing a new one.`
656
+ : ""
657
+ return { ...summary, next_step: `This session turns passed runs into tests: call write_test with run_dir ${runDir} now, before you reply.${how}` }
477
658
  }
478
659
  return summary
479
660
  } catch (err) {
@@ -507,7 +688,7 @@ export const TOOLS: McpTool[] = [
507
688
  {
508
689
  name: "save_test_cases",
509
690
  description:
510
- "Save test cases you wrote from a story into Azure DevOps (Test Case work items with steps and expected results, linked to the story as Tests / Tested By, in the story's area and iteration) or Jira (issues of type Test by default, steps in the description, linked to the story). A case whose title matches one already linked to the story is reused, not duplicated. Returns each case's id and link, and shows them as a card.",
691
+ "Save test cases you wrote from a story into Azure DevOps (Test Case work items with steps and expected results, linked to the story as Tests / Tested By, in the story's area and iteration) or Jira (issues of type Test by default, steps in the description, linked to the story). Never duplicates: each case is compared with the cases already linked to the story. Same steps (under any title): unchanged, nothing written. Same title, other steps: differs, nothing written; propose the new steps with update_test_case. A close title or mostly the same steps: similar, nothing written; update that case, or save again with the title in force_new when it is really a different case. Returns each case's status, id, and link, and shows them as a card.",
511
692
  inputSchema: {
512
693
  type: "object",
513
694
  properties: {
@@ -531,6 +712,7 @@ export const TOOLS: McpTool[] = [
531
712
  },
532
713
  issue_type: { type: "string", description: "Jira only: issue type for the cases (default Test)" },
533
714
  link_type: { type: "string", description: "Jira only: link type to the story (default Relates)" },
715
+ force_new: { type: "array", items: { type: "string" }, description: "Titles to create although a similar case is linked, only after the user said they are different cases" },
534
716
  },
535
717
  required: ["system", "story", "cases"],
536
718
  },
@@ -539,29 +721,55 @@ export const TOOLS: McpTool[] = [
539
721
  if (!cases.length) throw new Error("cases is empty.")
540
722
  const story = String(args.story || "").trim()
541
723
  if (!story) throw new Error("story is required.")
724
+ const forceNew = Array.isArray(args.force_new) ? (args.force_new as unknown[]).map(String) : undefined
542
725
  const saved =
543
726
  args.system === "jira"
544
- ? await saveJiraCases(ctx.config.sources, story, cases, { issueType: args.issue_type ? String(args.issue_type) : undefined, linkType: args.link_type ? String(args.link_type) : undefined })
545
- : await saveAdoCases(ctx.config.sources, Number(story.replace(/^#/, "")), cases)
546
- const made = saved.cases.filter((c) => c.created).length
547
- const failed = saved.cases.filter((c) => !c.id).length
727
+ ? await saveJiraCases(ctx.config.sources, story, cases, { issueType: args.issue_type ? String(args.issue_type) : undefined, linkType: args.link_type ? String(args.link_type) : undefined, forceNew })
728
+ : await saveAdoCases(ctx.config.sources, Number(story.replace(/^#/, "")), cases, { forceNew })
729
+ const count = (st: SaveStatus) => saved.cases.filter((c) => c.status === st).length
730
+ const MARKS: Record<SaveStatus, string> = { created: "+", unchanged: "=", differs: "~", similar: "?", failed: "✗" }
731
+ const failed = count("failed")
548
732
  ctx.emit?.({
549
733
  type: "card",
550
734
  card: {
551
- status: failed ? "fail" : "ok",
735
+ status: failed ? "fail" : count("differs") || count("similar") ? "info" : "ok",
552
736
  title: `Test cases saved for ${saved.story.id}${saved.story.title ? ` ${saved.story.title}` : ""}`,
553
- stats: [`${made} created`, saved.cases.length - made - failed ? `${saved.cases.length - made - failed} already linked` : "", failed ? `${failed} failed` : ""].filter(Boolean).join(" · "),
737
+ stats: [`${count("created")} created`, count("unchanged") ? `${count("unchanged")} already linked` : "", count("differs") ? `${count("differs")} differ from the linked case` : "", count("similar") ? `${count("similar")} like a linked case` : "", failed ? `${failed} failed` : ""].filter(Boolean).join(" · "),
554
738
  body: [
555
739
  `Story ${saved.story.url}`,
556
740
  "",
557
- ...saved.cases.flatMap((c) => (c.id ? [`${c.created ? "+" : "="} ${c.id} ${c.title}`, ` ${c.url}`, ...(c.error ? [` ${c.error}`] : [])] : [`✗ ${c.title}: ${c.error}`])),
741
+ ...saved.cases.flatMap((c) =>
742
+ c.id
743
+ ? [
744
+ `${MARKS[c.status]} ${c.id} ${c.title}`,
745
+ ...(c.existing && c.existing.title !== c.title ? [` linked as "${c.existing.title}"`] : []),
746
+ ` ${c.url}`,
747
+ ...(c.error ? [` ${c.error}`] : []),
748
+ ]
749
+ : [`✗ ${c.title}: ${c.error}`],
750
+ ),
558
751
  ],
559
752
  },
560
753
  })
754
+ const show = (steps: NewCase["steps"]) => steps.map((st, i) => `${i + 1}. ${st.action}${st.expected ? ` → ${st.expected}` : ""}`)
755
+ const pending = saved.cases.filter((c) => c.status === "differs" || c.status === "similar")
561
756
  return {
562
757
  story: saved.story,
563
- cases: saved.cases.map((c) => ({ title: c.title, id: c.id || undefined, url: c.url || undefined, created: c.created, error: c.error })),
564
- ...(saved.system === "azure_devops" && saved.cases.some((c) => c.id) ? { run_with: { source: "azure_devops", ids: saved.cases.filter((c) => c.id).map((c) => c.id) } } : {}),
758
+ cases: saved.cases.map((c) => ({
759
+ title: c.title,
760
+ status: c.status,
761
+ id: c.id || undefined,
762
+ url: c.url || undefined,
763
+ ...(c.existing ? { linked_case: { id: c.existing.id, title: c.existing.title, steps: show(c.existing.steps) } } : {}),
764
+ error: c.error,
765
+ })),
766
+ ...(saved.system === "azure_devops" && saved.cases.some((c) => c.id) ? { run_with: { source: "azure_devops", ids: [...new Set(saved.cases.filter((c) => c.id).map((c) => c.id))] } } : {}),
767
+ ...(pending.length
768
+ ? {
769
+ next_step:
770
+ "Nothing was written for the cases with status differs or similar. Do not say they were saved or updated. For each one, show the user how it differs from linked_case and offer update_test_case with the steps you wrote (id: linked_case.id). Save again with the title in force_new only when the user says it is a different case.",
771
+ }
772
+ : {}),
565
773
  }
566
774
  },
567
775
  },
@@ -918,6 +1126,7 @@ export const TOOLS: McpTool[] = [
918
1126
  ...COMMAND_TOOLS,
919
1127
  ...REPO_TEST_TOOLS,
920
1128
  ...DEFECT_TOOLS,
1129
+ ...PACK_TOOLS,
921
1130
  ...PR_TOOLS,
922
1131
  ...CASE_TOOLS,
923
1132
  ...API_TOOLS,