@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
@@ -15,6 +15,7 @@ import os
15
15
  import re
16
16
  import time
17
17
  from dataclasses import dataclass, field
18
+ from datetime import datetime, timezone
18
19
  from pathlib import Path
19
20
  from typing import Any, Callable, Dict, List, Optional, Set, Tuple
20
21
 
@@ -25,6 +26,7 @@ from .memory import READ_ONLY_ACTIONS, MemoryStore, compact_step, flow_step, pag
25
26
  from .prompts import CONVENTIONS, text as prompt_text, use_prompts
26
27
  from .report import CASE_REPORT_INSTRUCTION, CaseReport, build_timeline, case_report_from_history
27
28
  from .replay import ReplayOutcome, handoff_task, replay_flow
29
+ from .observe import check_key, checkable, checks_for_flow
28
30
  from .settings import (
29
31
  _truthy_env,
30
32
  _browser_use_agent_tuning_kwargs,
@@ -107,6 +109,9 @@ class RunRequest:
107
109
  logins: List[Dict[str, Any]] = field(default_factory=list)
108
110
  # Number of steps in the test case this goal came from; the agent then reports a verdict for each.
109
111
  case_steps: int = 0
112
+ # The test case and a key per step (changes when the step is reworded): an edited case replays its unchanged steps.
113
+ case_id: str = ""
114
+ case_step_keys: List[str] = field(default_factory=list)
110
115
  # Step screenshots for the report: "all", "failures", or "off".
111
116
  screenshots: str = "all"
112
117
  # Accessibility checks on each page visited, for the report.
@@ -139,6 +144,8 @@ class RunRequest:
139
144
  return cls(
140
145
  logins=[x for x in (d.get("logins") or []) if isinstance(x, dict)],
141
146
  case_steps=int(d["case_steps"]) if str(d.get("case_steps") or "").isdigit() else 0,
147
+ case_id=str(d.get("case_id") or ""),
148
+ case_step_keys=[str(k) for k in (d.get("case_step_keys") or []) if str(k)],
142
149
  screenshots=str(d.get("screenshots") or "all").lower(),
143
150
  accessibility=d.get("accessibility") is not False,
144
151
  overlay=d.get("overlay") is not False,
@@ -395,6 +402,41 @@ def serialize_browser_step_payload(browser_state: Any, agent_output: Any, n_step
395
402
 
396
403
 
397
404
  REPLAY_MODES = ("verify", "trust", "off")
405
+ # checks_first (docs/COST_ARCHITECTURE.md): every Nth replay of a flow is still checked by the agent, so a check that
406
+ # passes when it should not is caught; and a flow not passed for this long is checked by the agent again.
407
+ CANARY_EVERY = 10
408
+ MAX_FLOW_AGE_DAYS = 30
409
+
410
+
411
+ def _int(raw: Any, default: int) -> int:
412
+ try:
413
+ n = int(raw)
414
+ except (TypeError, ValueError):
415
+ return default
416
+ return n if n >= 0 else default
417
+
418
+
419
+ def judged_by_checks(flow: Dict[str, Any], replay: Any, mem: "MemoryRun") -> Optional[Dict[str, Any]]:
420
+ """
421
+ After a complete replay of an exact flow: whether its recorded checks decide the run with no model. ok: every
422
+ check was found on the page again. Otherwise `why` says what leaves it to the agent (today's verify path).
423
+ """
424
+ why = checkable(flow)
425
+ if why:
426
+ return {"ok": False, "why": why}
427
+ if len(replay.checked) < len(flow.get("checks") or []) or not all(c.get("holds_now") for c in replay.checked):
428
+ return {"ok": False, "why": "not every recorded check was found on the page again"}
429
+ replays = int(flow.get("replays") or 0) + 1
430
+ if mem.canary_every and replays % mem.canary_every == 0:
431
+ return {"ok": False, "why": f"replay {replays} of this flow: every {mem.canary_every}th is checked by the agent too"}
432
+ last = str(flow.get("last_success") or "")
433
+ try:
434
+ age = (datetime.now(timezone.utc) - datetime.fromisoformat(last.replace("Z", "+00:00"))).days if last else None
435
+ except ValueError:
436
+ age = None
437
+ if age is None or age > mem.max_age_days:
438
+ return {"ok": False, "why": f"the flow last passed more than {mem.max_age_days} days ago" if age is not None else "the flow has no recorded pass"}
439
+ return {"ok": True, "why": ""}
398
440
 
399
441
 
400
442
  @dataclass
@@ -418,6 +460,59 @@ class MemoryRun:
418
460
  used_checkpoint: str = ""
419
461
  # Actions that worked without changing the page (dead clicks, read-only scripts): not part of the flow.
420
462
  no_effect: List[str] = field(default_factory=list)
463
+ # The test case this run is for, and a key per case step.
464
+ case_id: str = ""
465
+ case_keys: List[str] = field(default_factory=list)
466
+ # Case step number -> flow steps done when its expected result was recorded (the end of that step).
467
+ check_at: Dict[int, int] = field(default_factory=dict)
468
+ # Ends of the case steps a replay already did, carried over from the flow that was replayed.
469
+ carried_ends: List[Optional[int]] = field(default_factory=list)
470
+ # registry.mode on (docs/COST_ARCHITECTURE.md): the flow keeps its checks and a replay is judged by them.
471
+ checks_first: bool = False
472
+ # The checks the replayed flow had, and those the agent recorded this run with the flow step each came after.
473
+ flow_checks: List[Dict[str, Any]] = field(default_factory=list)
474
+ recorded: List[Dict[str, Any]] = field(default_factory=list)
475
+ check_pos: Dict[str, int] = field(default_factory=dict)
476
+ check_text: Dict[str, Any] = field(default_factory=dict)
477
+ canary_every: int = CANARY_EVERY
478
+ max_age_days: int = MAX_FLOW_AGE_DAYS
479
+
480
+ def checks_to_save(self, status: str) -> Optional[List[Dict[str, Any]]]:
481
+ """The checks the flow keeps after this run; None leaves the flow's as they are."""
482
+ if not self.checks_first or status != "complete":
483
+ return None
484
+ new = checks_for_flow(self.recorded, self.check_pos)
485
+ if not self.llm_steps:
486
+ # A replay judged by its checks: the same steps, so the same checks.
487
+ return list(self.flow_checks) if self.flow_checks else None
488
+ # Checks from the replayed part still stand where the agent did not record them again.
489
+ done = len(self.replay.done) if self.replay else 0
490
+ keys = {check_key(c) for c in new}
491
+ carried = [c for c in self.flow_checks if 0 < int(c.get("at") or 0) <= done and check_key(c) not in keys]
492
+ return carried + new
493
+
494
+ def note_checks(self, checks: List[Dict[str, Any]]) -> None:
495
+ """After an agent step: each case step whose check was just recorded ends at the flow as it is now."""
496
+ self.recorded = checks
497
+ for c in checks:
498
+ key = check_key(c)
499
+ # A check recorded again (the same expected result, new text) moves to where it was recorded last.
500
+ if key and self.check_text.get(key) != c.get("text"):
501
+ self.check_pos[key] = len(self.steps)
502
+ self.check_text[key] = c.get("text")
503
+ for c in checks:
504
+ n = c.get("step")
505
+ if isinstance(n, int) and 0 < n <= len(self.case_keys) and n not in self.check_at:
506
+ self.check_at[n] = len(self.steps)
507
+
508
+ def case_ends(self) -> List[Optional[int]]:
509
+ ends: List[Optional[int]] = [None] * len(self.case_keys)
510
+ for i, e in enumerate(self.carried_ends[: len(ends)]):
511
+ ends[i] = e
512
+ for n, at in self.check_at.items():
513
+ if ends[n - 1] is None:
514
+ ends[n - 1] = at
515
+ return ends
421
516
 
422
517
  def checkpoint(self) -> Optional[Dict[str, Any]]:
423
518
  """Save the steps that worked so far and the page memory learned so far, so a run cut off here can be picked up."""
@@ -447,7 +542,14 @@ class MemoryRun:
447
542
  if not folder:
448
543
  return None
449
544
  mode = str((req.memory or {}).get("replay") or "verify").lower()
450
- return cls(MemoryStore(folder), mode if mode in REPLAY_MODES else "verify", req.goal, start_url)
545
+ mem = req.memory or {}
546
+ return cls(
547
+ MemoryStore(folder), mode if mode in REPLAY_MODES else "verify", req.goal, start_url,
548
+ case_id=req.case_id, case_keys=list(req.case_step_keys) if req.case_id else [],
549
+ checks_first=bool(mem.get("checks_first")),
550
+ canary_every=_int(mem.get("canary_every"), CANARY_EVERY),
551
+ max_age_days=_int(mem.get("max_age_days"), MAX_FLOW_AGE_DAYS),
552
+ )
451
553
 
452
554
  def finish(self, out: Dict[str, Any]) -> Dict[str, Any]:
453
555
  self.finished = True
@@ -470,6 +572,8 @@ class MemoryRun:
470
572
  replayed=len(self.replay.done) if self.replay else 0,
471
573
  llm_steps=self.llm_steps,
472
574
  resume=self.resume if status == "fail" else None,
575
+ case={"id": self.case_id, "keys": self.case_keys, "ends": self.case_ends()} if self.case_id and self.case_keys else None,
576
+ checks=self.checks_to_save(status),
473
577
  )
474
578
  except Exception as ex:
475
579
  logger.warning("memory: could not save the flow: %s", ex)
@@ -494,6 +598,11 @@ class MemoryRun:
494
598
  info["repaired"] = {"step": self.replay.stopped_at, "reason": self.replay.reason, "agent_steps": self.llm_steps}
495
599
  if self.replay and self.replay.heals:
496
600
  info["heals"] = self.replay.heals
601
+ if self.replay and self.replay.checked:
602
+ info["checked"] = len(self.replay.checked)
603
+ info["checks_held"] = sum(1 for c in self.replay.checked if c.get("holds_now"))
604
+ if self.replay and self.replay.skipped_reads:
605
+ info["skipped_reads"] = self.replay.skipped_reads
497
606
  if self.resume and status == "fail":
498
607
  info["resume"] = {
499
608
  "steps": len(self.resume["steps"]),
@@ -788,7 +897,7 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
788
897
  except Exception as e:
789
898
  return {"success": False, "error": str(e), "summary": ""}
790
899
  llm_calls: List[Tuple[float, float]] = []
791
- _time_llm_calls(llm, llm_calls)
900
+ _time_llm_calls(llm, llm_calls, lambda c: emit({"type": "call", **c}))
792
901
  use_prompts(req.prompts)
793
902
  diag = Diagnostics(accessibility=req.accessibility, network=req.network, axe=req.axe or None, vitals=req.vitals, visual=req.visual)
794
903
 
@@ -920,6 +1029,13 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
920
1029
  mem.used_checkpoint = str(checkpoint.get("run_id") or "")
921
1030
  elif saved and isinstance(saved.get("resume"), dict) and saved["resume"].get("steps"):
922
1031
  flow, kind = saved["resume"], "resume"
1032
+ elif mem.case_id and mem.case_keys:
1033
+ # The test case was edited (its goal is new): replay its steps before the first one that changed.
1034
+ earlier = mem.store.load_case_flow(mem.case_id)
1035
+ if earlier and str(earlier.get("start_url") or "") == (normalized_initial or ""):
1036
+ done, end = MemoryStore.case_prefix(earlier, mem.case_keys)
1037
+ if done:
1038
+ flow, kind = {**earlier, "steps": earlier["steps"][:end], "case_steps_done": done}, "prefix"
923
1039
  if flow and kind and flow.get("steps"):
924
1040
  if kind == "interrupted":
925
1041
  replay_text = (
@@ -932,6 +1048,11 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
932
1048
  + (f" ({flow['run_id']})" if flow.get("run_id") else "")
933
1049
  + (f", which went wrong at step {flow['failed_at']}" if flow.get("failed_at") else "")
934
1050
  )
1051
+ elif kind == "prefix":
1052
+ replay_text = (
1053
+ f"The test case changed after step {flow['case_steps_done']}: replaying the {len(flow['steps'])} recorded "
1054
+ f"steps that did steps 1 to {flow['case_steps_done']}, which did not change"
1055
+ )
935
1056
  else:
936
1057
  replay_text = (
937
1058
  f"Replaying {len(flow['steps'])} recorded steps from memory "
@@ -940,13 +1061,19 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
940
1061
  emit({"type": "memory", "text": replay_text})
941
1062
  await browser.start()
942
1063
  await diag.start(browser)
1064
+ mem.flow_checks = [c for c in (flow.get("checks") or []) if isinstance(c, dict)] if mem.checks_first else []
943
1065
  replay = await replay_flow(
944
- flow, browser=browser, store=mem.store, emit=emit, cancel_event=cancel_event, on_step=lambda: diag.sample(browser), overlay=overlay
1066
+ flow, browser=browser, store=mem.store, emit=emit, cancel_event=cancel_event, on_step=lambda: diag.sample(browser), overlay=overlay,
1067
+ checks_first=mem.checks_first and kind == "exact",
945
1068
  )
946
1069
  mem.replay = replay
947
1070
  for done_step in replay.done:
948
1071
  mem.add(done_step)
949
1072
  replay_offset = replay.emitted
1073
+ if replay.complete and kind in ("exact", "prefix"):
1074
+ # The case steps the replay did end where they ended in the recorded run.
1075
+ known = list(flow.get("case_step_ends") or [])
1076
+ mem.carried_ends = known[: int(flow.get("case_steps_done") or len(known))]
950
1077
  if replay.complete and kind == "exact":
951
1078
  emit({"type": "memory", "text": f"Replayed all {len(replay.done)} steps without the LLM"})
952
1079
  elif replay.complete:
@@ -958,6 +1085,30 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
958
1085
  out = {"success": False, "error": err, "summary": err, "step_events": collected_steps, "cancelled": True}
959
1086
  _save(out, "cancelled")
960
1087
  return out
1088
+ judged = judged_by_checks(flow, replay, mem) if replay.complete and kind == "exact" and mem.checks_first and mem.mode == "verify" else None
1089
+ if judged and judged["ok"]:
1090
+ held = replay.checked
1091
+ summary = f"Replayed all {len(replay.done)} recorded steps and found all {len(held)} recorded checks on the page again, without calling the LLM."
1092
+ if flow.get("summary"):
1093
+ summary += f"\n\nResult of the run that recorded them: {flow['summary']}"
1094
+ emit({"type": "memory", "text": f"All {len(held)} recorded checks hold: passed without the LLM"})
1095
+ out = {
1096
+ "success": True,
1097
+ "outcome": "passed",
1098
+ "summary": summary,
1099
+ "result_markdown": f"### Task\n\n{goal}\n\n---\n\n## Result\n\n{summary}",
1100
+ "session_id": req.session_id,
1101
+ "final_url": replay.url,
1102
+ "usage": {"input": 0, "output": 0},
1103
+ "steps": replay.emitted,
1104
+ "step_events": collected_steps,
1105
+ "checks": [{k: v for k, v in c.items() if k in ("expected", "text", "url", "step")} for c in held],
1106
+ "browser": await browser_profile(browser, browser_kw, provider=browser_provider, remote=browser_remote),
1107
+ }
1108
+ _save(out, "memory_checks")
1109
+ return out
1110
+ if judged and not judged["ok"]:
1111
+ emit({"type": "memory", "text": f"The agent checks the result: {judged['why']}"})
961
1112
  if replay.complete and mem.mode == "trust" and kind == "exact":
962
1113
  summary = f"Replayed all {len(replay.done)} recorded steps from memory without calling the LLM."
963
1114
  if flow.get("summary"):
@@ -1141,6 +1292,7 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
1141
1292
  mem, pending_actions, results, mem.llm_steps, agent_note(getattr(last, "model_output", None)),
1142
1293
  effects=list(action_effects), page_url=step_url["url"],
1143
1294
  )
1295
+ mem.note_checks(recorded_checks)
1144
1296
  try:
1145
1297
  point = mem.checkpoint()
1146
1298
  if point:
@@ -111,19 +111,20 @@ export const CAPABILITIES: Capability[] = [
111
111
  area: "Test cases",
112
112
  title: "Check and improve test cases",
113
113
  summary:
114
- "Checks test cases before they run (vague or missing expected results, missing test data, placeholders, duplicates) against the story, feature, and epic they test, then rewrites Azure DevOps cases once the user agrees.",
114
+ "Checks test cases before they run (vague or missing expected results, missing test data, placeholders, duplicates) against the story, feature, and epic they test, then rewrites Azure DevOps or Jira cases in place once the user agrees.",
115
115
  tools: ["check_test_cases", "update_test_case"],
116
116
  examples: ["Are the cases in test plan 12 good enough to run?", "Fix the expected results of case 4512"],
117
- requires: [{ kind: "system", anyOf: ["azure_devops"], optional: true, why: "to rewrite cases in place" }],
117
+ requires: [{ kind: "system", anyOf: ["azure_devops", "jira"], optional: true, why: "to rewrite cases in place" }],
118
118
  docs: [{ doc: "HOW_TO_USE.md", heading: "Checking test cases before they run" }],
119
119
  },
120
120
  {
121
121
  id: "write-test-cases",
122
122
  area: "Test cases",
123
123
  title: "Write test cases from a story",
124
- summary: "Writes test cases from a story's acceptance criteria and saves them in Azure DevOps or Jira, linked to the story.",
125
- tools: ["save_test_cases"],
126
- examples: ["Write test cases for story 1234 and save them"],
124
+ summary:
125
+ "Writes test cases from a story's acceptance criteria and saves them in Azure DevOps or Jira, linked to the story. Reads the cases already linked first, so asking again adds only what is missing and updates cases in place instead of duplicating them; plans end-to-end journeys from the existing cases.",
126
+ tools: ["story_test_cases", "save_test_cases", "e2e_journey"],
127
+ examples: ["Write test cases for story 1234 and save them", "Build an end-to-end journey from story 1234's cases"],
127
128
  requires: [{ kind: "system", anyOf: ["azure_devops", "jira"] }],
128
129
  docs: [{ doc: "HOW_TO_USE.md", heading: "Where cases come from" }],
129
130
  },
@@ -185,6 +186,19 @@ export const CAPABILITIES: Capability[] = [
185
186
  ],
186
187
  docs: [{ doc: "HOW_TO_USE.md", heading: "Finding and running existing tests" }],
187
188
  },
189
+ {
190
+ id: "regression-packs",
191
+ area: "Automated tests",
192
+ title: "Regression packs for a release",
193
+ summary: "Builds a regression pack from what goes into a release (an Azure DevOps sprint or work items, a commit range, pull requests, or changes read from any connected tool): the tests those changes can break, each with why, plus risk-area and smoke tests, and the changes no test covers. Runs it locally, on TestMu AI, or in a pipeline, and compares each run with the last.",
194
+ tools: ["build_regression_pack", "run_regression_pack", "list_regression_packs"],
195
+ examples: ["Build a regression pack for sprint 12 and run it on TestMu", "Which tests should we run for the PRs merged this week?"],
196
+ requires: [
197
+ { kind: "input", what: "a repository with tests" },
198
+ { kind: "system", anyOf: ["azure_devops", "jira"], optional: true, why: "to read a sprint's or release's work items" },
199
+ ],
200
+ docs: [{ doc: "HOW_TO_USE.md", heading: "Regression packs for a release" }],
201
+ },
188
202
  {
189
203
  id: "flaky-tests",
190
204
  area: "Automated tests",
package/src/cases/ado.ts CHANGED
@@ -192,7 +192,7 @@ const xml = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "", pr
192
192
  type Node = Record<string, unknown> & { ":@"?: Record<string, string> }
193
193
 
194
194
  /** Microsoft.VSTS.TCM.Steps: `<step>` action/expected pairs in order, with shared steps (`<compref ref>`) inlined. */
195
- async function parseSteps(source: string, base: string, auth: string, depth = 0): Promise<CaseStep[]> {
195
+ export async function parseSteps(source: string, base: string, auth: string, depth = 0): Promise<CaseStep[]> {
196
196
  if (!source.trim()) return []
197
197
  const steps: CaseStep[] = []
198
198
  const walk = async (nodes: Node[]) => {
@@ -0,0 +1,61 @@
1
+ import { stepText, type CaseStep } from "./types.ts"
2
+
3
+ /** A test case as a journey uses it. */
4
+ export type JourneyCase = { id: string; title: string; url?: string; startUrl?: string; steps: CaseStep[] }
5
+
6
+ /** One case inside a journey: its steps are `from`-`to` of the journey (1-based), after the shared steps. */
7
+ export type JourneySection = { caseId: string; title: string; from: number; to: number; negative: boolean }
8
+
9
+ export type Journey = { startUrl?: string; shared: CaseStep[]; steps: CaseStep[]; sections: JourneySection[]; saved: number }
10
+
11
+ /** Steps that look like a negative path: a refusal, an error, or one that ends the session or removes data. */
12
+ const NEGATIVE = /\b(invalid|incorrect|wrong|error|denied|refused|rejects?|rejected|fails?|failed|locked|not allowed|cannot|can't|unauthori[sz]ed|forbidden|sign(?:s|ed)? out|log(?:s|ged)? out|delete[sd]?|remove[sd]?)\b/i
13
+
14
+ const key = (s: CaseStep) => `${stepText(s.action)}|${stepText(s.expected)}`
15
+
16
+ function sharedPrefix(cases: JourneyCase[]): number {
17
+ let n = 0
18
+ for (;;) {
19
+ const first = cases[0].steps[n]
20
+ if (!first || cases.some((c) => !c.steps[n] || key(c.steps[n]) !== key(first))) return n
21
+ n++
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Journeys from cases that start the same way: same start page and first step. The steps they all share come once,
27
+ * then each case's own steps follow as a section; cases that look negative go last, since they may end the session.
28
+ * A case that starts like no other stays on its own. The draft keeps the cases' order otherwise: the agent reorders it.
29
+ */
30
+ export function planJourneys(cases: JourneyCase[]): { journeys: Journey[]; alone: JourneyCase[]; stepsSaved: number } {
31
+ const groups = new Map<string, JourneyCase[]>()
32
+ for (const c of cases) {
33
+ if (!c.steps.length) continue
34
+ const g = `${c.startUrl || ""}\n${key(c.steps[0])}`
35
+ groups.set(g, [...(groups.get(g) || []), c])
36
+ }
37
+ const journeys: Journey[] = []
38
+ const alone: JourneyCase[] = cases.filter((c) => !c.steps.length)
39
+ for (const group of groups.values()) {
40
+ if (group.length < 2) {
41
+ alone.push(...group)
42
+ continue
43
+ }
44
+ const n = sharedPrefix(group)
45
+ const shared = group[0].steps.slice(0, n)
46
+ const ordered = group
47
+ .map((c) => ({ c, tail: c.steps.slice(n) }))
48
+ .map((x) => ({ ...x, negative: NEGATIVE.test(x.tail.map((s) => `${s.action} ${s.expected || ""}`).join(" ")) }))
49
+ .sort((a, b) => Number(a.negative) - Number(b.negative))
50
+ const steps = [...shared]
51
+ const sections: JourneySection[] = []
52
+ for (const { c, tail, negative } of ordered) {
53
+ // A case that is only the shared steps is covered by them.
54
+ const from = tail.length ? steps.length + 1 : 1
55
+ steps.push(...tail)
56
+ sections.push({ caseId: c.id, title: c.title, from, to: tail.length ? steps.length : n, negative })
57
+ }
58
+ journeys.push({ startUrl: group[0].startUrl, shared, steps, sections, saved: (group.length - 1) * n })
59
+ }
60
+ return { journeys, alone, stepsSaved: journeys.reduce((sum, j) => sum + j.saved, 0) }
61
+ }
@@ -20,7 +20,8 @@ import { adoSprints, findSprint } from "./sprints.ts"
20
20
  * runs: the newest suite report for the case, then the automated test's history.
21
21
  */
22
22
 
23
- export type Scope = { sprint?: string; area?: string; parent?: number; ids?: number[]; project?: string }
23
+ /** iteration: an iteration path and everything under it, such as a release ("Release 2026.09", or Project\Release 2026.09). */
24
+ export type Scope = { sprint?: string; iteration?: string; area?: string; parent?: number; ids?: number[]; project?: string }
24
25
 
25
26
  export type Outcome = "passed" | "failed" | "blocked" | "not run"
26
27
  export type CaseRow = { id: number; title: string; url: string; automated: boolean; test?: string; outcome: Outcome; when?: string; from?: "Azure DevOps" | "ForgeQE"; flaky?: boolean; quarantined?: boolean }
@@ -118,11 +119,26 @@ function forgeqeResults(suitesDir: string): Map<number, { outcome: Outcome; when
118
119
  return out
119
120
  }
120
121
 
121
- export async function releaseReadiness(cfg: AppConfig, repo: string, scope: Scope, thresholds: ReleaseThresholds = DEFAULT_THRESHOLDS): Promise<Readiness> {
122
+ export type WorkItem = Item
123
+
124
+ /**
125
+ * An Azure DevOps scope as work items: a sprint, an area, a parent epic or feature and everything under it, or given
126
+ * ids. Gives the stories in it, the WIQL filter its other work items share (empty for a parent or ids), and helpers to
127
+ * query and read more. Release readiness and regression packs both start here.
128
+ */
129
+ export async function resolveScope(cfg: AppConfig, scope: Scope): Promise<{
130
+ label: string
131
+ filter: string
132
+ project?: string
133
+ org: string
134
+ storyIds: number[]
135
+ wiql: (query: string) => Promise<number[]>
136
+ items: (ids: number[]) => Promise<Item[]>
137
+ urlOf: (it: Item) => string
138
+ }> {
122
139
  const { org, auth, project: configured } = adoSettings(cfg.sources)
123
140
  const project = scope.project || configured
124
141
  if (!project && !scope.ids?.length) throw new Error("Name the Azure DevOps project (project, or sources.azure_devops.project) for a sprint, area, or epic.")
125
- const notes: string[] = []
126
142
  const wiql = async (query: string) => {
127
143
  const { data } = await http<{ workItems?: { id: number }[]; workItemRelations?: { target?: { id: number } }[] }>(`${org}/${encodeURIComponent(project!)}/_apis/wit/wiql?api-version=7.1`, { auth, body: { query } })
128
144
  return [...new Set([...(data?.workItems || []).map((w) => w.id), ...(data?.workItemRelations || []).map((r) => r.target?.id).filter((n): n is number => Boolean(n))])]
@@ -130,8 +146,9 @@ export async function releaseReadiness(cfg: AppConfig, repo: string, scope: Scop
130
146
  const items = async (ids: number[]) => {
131
147
  const out: Item[] = []
132
148
  for (let i = 0; i < ids.length; i += 200) {
133
- const { data } = await http<{ value?: Item[] }>(`${org}/_apis/wit/workitems?ids=${ids.slice(i, i + 200).join(",")}&$expand=relations&api-version=7.1`, { auth })
134
- out.push(...(data?.value || []))
149
+ // errorPolicy=omit: one id that does not exist, or that the token cannot read, does not lose the others.
150
+ const { data } = await http<{ value?: (Item | null)[] }>(`${org}/_apis/wit/workitems?ids=${ids.slice(i, i + 200).join(",")}&$expand=relations&errorPolicy=omit&api-version=7.1`, { auth })
151
+ out.push(...(data?.value || []).filter((w): w is Item => Boolean(w)))
135
152
  }
136
153
  return out
137
154
  }
@@ -156,14 +173,26 @@ export async function releaseReadiness(cfg: AppConfig, repo: string, scope: Scop
156
173
  parts.push(`[System.IterationPath] UNDER ${q(s.path)}`)
157
174
  label = `sprint ${s.name}${s.start ? ` (${s.start} to ${s.end})` : ""}`
158
175
  } else label = ""
176
+ if (scope.iteration) {
177
+ const iterationPath = scope.iteration.includes("\\") || !project ? scope.iteration : `${project}\\${scope.iteration}`
178
+ parts.push(`[System.IterationPath] UNDER ${q(iterationPath)}`)
179
+ label = [label, `iteration ${iterationPath}`].filter(Boolean).join(", ")
180
+ }
159
181
  if (scope.area) {
160
182
  parts.push(`[System.AreaPath] UNDER ${q(scope.area)}`)
161
183
  label = [label, `area ${scope.area}`].filter(Boolean).join(", ")
162
184
  }
163
- if (!parts.length) throw new Error("Give a sprint, an area, a parent epic or feature, or work item ids.")
185
+ if (!parts.length) throw new Error("Give a sprint, an iteration path, an area, a parent epic or feature, or work item ids.")
164
186
  filter = parts.join(" AND ")
165
187
  storyIds = await wiql(`SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND ${REQUIREMENT} AND [System.State] <> 'Removed' AND ${filter}`)
166
188
  }
189
+ return { label, filter, ...(project ? { project } : {}), org, storyIds, wiql, items, urlOf }
190
+ }
191
+
192
+ export async function releaseReadiness(cfg: AppConfig, repo: string, scope: Scope, thresholds: ReleaseThresholds = DEFAULT_THRESHOLDS): Promise<Readiness> {
193
+ const { org, auth } = adoSettings(cfg.sources)
194
+ const notes: string[] = []
195
+ const { label, filter, project, storyIds, wiql, items, urlOf } = await resolveScope(cfg, scope)
167
196
  const storyItems = await items(storyIds)
168
197
 
169
198
  // Test cases each story is tested by, and their newest outcome.