devops-bot-sdk 1.4.94__tar.gz → 1.4.99__tar.gz

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 (42) hide show
  1. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/devops_bot_sdk.egg-info/SOURCES.txt +4 -0
  4. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/pyproject.toml +1 -1
  5. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/__init__.py +2 -2
  6. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/client.py +11 -3
  7. devops_bot_sdk-1.4.99/sdk/design_verify/__init__.py +51 -0
  8. devops_bot_sdk-1.4.99/sdk/design_verify/browser.py +195 -0
  9. devops_bot_sdk-1.4.99/sdk/design_verify/compare.py +150 -0
  10. devops_bot_sdk-1.4.99/sdk/design_verify/loop.py +169 -0
  11. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/ipc/handlers.py +15 -6
  12. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/README.md +0 -0
  13. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  14. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  15. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/devops_bot_sdk.egg-info/requires.txt +0 -0
  16. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  17. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/collectors/__init__.py +0 -0
  18. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/collectors/files.py +0 -0
  19. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/collectors/process.py +0 -0
  20. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/collectors/screenshot.py +0 -0
  21. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/config.py +0 -0
  22. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/crucial.py +0 -0
  23. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/exceptions.py +0 -0
  24. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/git_ops.py +0 -0
  25. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/graphify.py +0 -0
  26. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/hooks/__init__.py +0 -0
  27. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/hooks/crucial_guard.py +0 -0
  28. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/ipc/__init__.py +0 -0
  29. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/ipc/electron_bridge.py +0 -0
  30. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/local_exec.py +0 -0
  31. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/models/__init__.py +0 -0
  32. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/models/envelope.py +0 -0
  33. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/models/requests.py +0 -0
  34. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/models/responses.py +0 -0
  35. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/models/snapshots.py +0 -0
  36. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/py.typed +0 -0
  37. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/run_auto.py +0 -0
  38. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/sse.py +0 -0
  39. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/test.py +0 -0
  40. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/test_pipeline.py +0 -0
  41. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/sdk/updater.py +0 -0
  42. {devops_bot_sdk-1.4.94 → devops_bot_sdk-1.4.99}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.94
3
+ Version: 1.4.99
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.94
3
+ Version: 1.4.99
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -24,6 +24,10 @@ sdk/collectors/__init__.py
24
24
  sdk/collectors/files.py
25
25
  sdk/collectors/process.py
26
26
  sdk/collectors/screenshot.py
27
+ sdk/design_verify/__init__.py
28
+ sdk/design_verify/browser.py
29
+ sdk/design_verify/compare.py
30
+ sdk/design_verify/loop.py
27
31
  sdk/hooks/__init__.py
28
32
  sdk/hooks/crucial_guard.py
29
33
  sdk/ipc/__init__.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devops-bot-sdk"
7
- version = "1.4.94"
7
+ version = "1.4.99"
8
8
  description = "DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app"
9
9
  readme = "README.md"
10
10
  license = "LicenseRef-Proprietary"
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.4.94
3
+ Version: 1.4.99
4
4
 
5
5
  Public surface:
6
6
  BackendClient.from_config() — create client from ~/.agentos/config.toml
@@ -30,7 +30,7 @@ Rules:
30
30
  - All data egress through submit_webhook only
31
31
  """
32
32
 
33
- __version__ = "1.4.94"
33
+ __version__ = "1.4.99"
34
34
  __author__ = "AgentOS"
35
35
 
36
36
  from sdk.client import BackendClient
@@ -58,7 +58,7 @@ def _notify_status(task_id: str, status: str) -> None:
58
58
  pass
59
59
 
60
60
 
61
- SDK_VERSION = "1.4.94"
61
+ SDK_VERSION = "1.4.99"
62
62
  _POLL_INTERVAL = 3.0
63
63
  _POLL_TIMEOUT = 600.0
64
64
  _ORCHESTRATE_TIMEOUT = 2700.0 # 45 min — covers approval wait + VPS execution time
@@ -849,9 +849,17 @@ class BackendClient:
849
849
  headers = {k: v for k, v in self._headers.items() if k.lower() != "content-type"}
850
850
  headers.update({"accept": "*/*", "developer-name": "null", "product": ""})
851
851
  # POST /tasks-ml must never carry an SLA target — the backend validator
852
- # rejects any value not shaped like "8h", and SLA is not ours to set.
852
+ # rejects any value not shaped like "8h" (an empty "" 400s with
853
+ # "Slatarget must be a positive integer of hours…"), and SLA is not ours
854
+ # to set. Drop it by NORMALISED name so every casing/separator variant
855
+ # (slaTarget, slaTargets, sla_target, SLATarget, sla-target, …) is
856
+ # excluded — never sent as "" or anything else, always an empty field.
857
+ def _is_sla(key: str) -> bool:
858
+ norm = "".join(ch for ch in key.lower() if ch.isalnum())
859
+ return norm in ("slatarget", "slatargets")
860
+
853
861
  files = [(k, (None, str(v))) for k, v in fields.items()
854
- if v is not None and k not in ("slaTarget", "slaTargets")]
862
+ if v is not None and not _is_sla(k)]
855
863
  for m in (model_used or []):
856
864
  files.append(("modelUsed[]", (None, str(m)))) # [] → backend parses as array
857
865
  try:
@@ -0,0 +1,51 @@
1
+ """Visual design verification & refinement loop (prototype).
2
+
3
+ Why this exists
4
+ ---------------
5
+ Design-to-code fidelity is capped when the system generates a frontend ONCE
6
+ from a JIRA screenshot and never *looks* at what it built. This package closes
7
+ that loop:
8
+
9
+ generate -> render in a real browser -> screenshot -> compare against the
10
+ original design -> feed the differences back -> correct -> repeat.
11
+
12
+ It is deliberately decoupled from the main pipeline (nothing here is imported by
13
+ the live `handlers`/`local_exec` flow yet) so it can be developed and tested in
14
+ isolation, then slotted into `_code_one_repo` once proven.
15
+
16
+ Two external pieces it leans on — both already available, no new Python deps:
17
+
18
+ * ``agent-browser`` — Vercel Labs' Rust browser-automation CLI. Renders the
19
+ generated UI in headless Chrome and screenshots it. Install separately; see
20
+ ``README.md``. This is the ONLY new tool requirement.
21
+ * the local ``claude`` CLI — reused via :func:`sdk.local_exec.run_claude_local`.
22
+ Its built-in ``Read`` tool reads image files natively, so it doubles as the
23
+ vision model that diffs the two screenshots. No `anthropic`/`openai` package
24
+ is added.
25
+
26
+ What the caller must supply
27
+ ---------------------------
28
+ The one input this repo does not currently receive is the **original design
29
+ image** (the JIRA attachment). The backend hydrates task *text* only — attachment
30
+ URLs never reach the SDK today (see ``sdk/models/requests.py``). To use this
31
+ loop, the backend/operator must provide a local path to that screenshot. That
32
+ integration is out of scope for this prototype; the loop takes the path as an
33
+ argument.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ from .browser import BrowserError, DiffResult, diff_baseline, render_screenshot, snapshot
38
+ from .compare import ComparisonReport, compare_design
39
+ from .loop import RefinementResult, verify_and_refine
40
+
41
+ __all__ = [
42
+ "render_screenshot",
43
+ "snapshot",
44
+ "diff_baseline",
45
+ "DiffResult",
46
+ "BrowserError",
47
+ "compare_design",
48
+ "ComparisonReport",
49
+ "verify_and_refine",
50
+ "RefinementResult",
51
+ ]
@@ -0,0 +1,195 @@
1
+ """Thin async wrapper around the ``agent-browser`` CLI.
2
+
3
+ Only the two operations the verification loop needs are exposed: render a URL and
4
+ screenshot it to a file, and grab a structured accessibility snapshot. The
5
+ daemon that ``agent-browser`` runs behind the scenes starts on first command and
6
+ persists between calls, so successive screenshots in a refinement loop are cheap.
7
+
8
+ Command syntax mirrors the upstream CLI (vercel-labs/agent-browser):
9
+
10
+ agent-browser open <url> # navigate (aliases: navigate, goto)
11
+ agent-browser screenshot <path> # --full, --screenshot-format png|jpeg
12
+ agent-browser snapshot --json # accessibility tree with @e refs
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import re
18
+ import shutil
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ _BINARY = "agent-browser"
23
+ # A single browser op should never hang the loop; the daemon is local.
24
+ _CMD_TIMEOUT_S = 60.0
25
+
26
+ # agent-browser's `diff screenshot` prints human-readable lines; parse defensively
27
+ # rather than assume a stable JSON shape. Captures "12.34%" and "1234 pixels".
28
+ _PCT_RE = re.compile(r"([\d.]+)\s*%")
29
+ _PIXELS_RE = re.compile(r"([\d,]+)\s*(?:different\s+)?pixels", re.IGNORECASE)
30
+
31
+
32
+ class BrowserError(RuntimeError):
33
+ """Raised when the agent-browser CLI is missing or a command fails."""
34
+
35
+
36
+ @dataclass
37
+ class DiffResult:
38
+ """Outcome of a native `agent-browser diff screenshot` pixel comparison."""
39
+ mismatch_pct: float | None # None when a pixel % could not be determined
40
+ diff_pixels: int | None
41
+ diff_image: str | None # path to the red-highlighted diff image
42
+ dimension_mismatch: bool # baseline/current sizes differ -> no pixel diff
43
+ raw: str = ""
44
+
45
+
46
+ def is_available() -> bool:
47
+ """True if the ``agent-browser`` binary is on PATH."""
48
+ return shutil.which(_BINARY) is not None
49
+
50
+
51
+ async def _run(*args: str) -> str:
52
+ """Run one agent-browser subcommand, returning stdout (text)."""
53
+ if not is_available():
54
+ raise BrowserError(
55
+ f"`{_BINARY}` not found on PATH. Install it (see "
56
+ "sdk/design_verify/README.md) — it is the only external dependency "
57
+ "of the design verification loop."
58
+ )
59
+ proc = await asyncio.create_subprocess_exec(
60
+ _BINARY, *args,
61
+ stdout=asyncio.subprocess.PIPE,
62
+ stderr=asyncio.subprocess.PIPE,
63
+ )
64
+ try:
65
+ out, err = await asyncio.wait_for(proc.communicate(), timeout=_CMD_TIMEOUT_S)
66
+ except asyncio.TimeoutError as exc: # pragma: no cover - defensive
67
+ proc.kill()
68
+ raise BrowserError(f"`{_BINARY} {args[0]}` timed out after {_CMD_TIMEOUT_S}s") from exc
69
+ if proc.returncode != 0:
70
+ raise BrowserError(
71
+ f"`{_BINARY} {' '.join(args)}` failed (exit {proc.returncode}): "
72
+ f"{err.decode(errors='replace').strip()}"
73
+ )
74
+ return out.decode(errors="replace")
75
+
76
+
77
+ async def render_screenshot(
78
+ url: str,
79
+ out_path: str | Path,
80
+ *,
81
+ full_page: bool = False,
82
+ fmt: str = "png",
83
+ ) -> Path:
84
+ """Navigate to ``url`` and screenshot it to ``out_path``.
85
+
86
+ ``full_page`` captures the entire scrollable page (``--full``); the default
87
+ captures just the viewport, which usually matches a design mock's framing
88
+ better. Returns the resolved output path.
89
+ """
90
+ out = Path(out_path).expanduser().resolve()
91
+ out.parent.mkdir(parents=True, exist_ok=True)
92
+ await _run("open", url)
93
+ args = ["screenshot", str(out), "--screenshot-format", fmt]
94
+ if full_page:
95
+ args.append("--full")
96
+ await _run(*args)
97
+ if not out.exists():
98
+ raise BrowserError(f"screenshot reported success but {out} was not written")
99
+ return out
100
+
101
+
102
+ async def snapshot(url: str | None = None, *, interactive_only: bool = False) -> str:
103
+ """Return the page's accessibility tree as JSON text.
104
+
105
+ Lets the comparison assert *structure* (roles, labels, hierarchy) — not just
106
+ pixels. If ``url`` is given, navigates first; otherwise snapshots the current
107
+ page. Useful as a secondary signal when a pixel diff is ambiguous.
108
+ """
109
+ if url:
110
+ await _run("open", url)
111
+ args = ["snapshot", "--json"]
112
+ if interactive_only:
113
+ args.append("--interactive")
114
+ return await _run(*args)
115
+
116
+
117
+ async def diff_baseline(
118
+ baseline: str | Path,
119
+ *,
120
+ out_path: str | Path,
121
+ url: str | None = None,
122
+ threshold: float = 0.1,
123
+ ) -> DiffResult:
124
+ """Pixel-diff the current page against a ``baseline`` image.
125
+
126
+ Runs ``agent-browser diff screenshot --baseline <baseline> -o <out> -t <thr>``,
127
+ which screenshots the live page and compares it to ``baseline`` at the pixel
128
+ level, writing a red-highlighted diff image to ``out_path``. If ``url`` is
129
+ given, navigates there first.
130
+
131
+ This is a *quantitative, localizing* signal — mismatch % and a highlight
132
+ image — NOT a semantic judgement. Baselines whose dimensions differ from the
133
+ render yield ``dimension_mismatch=True`` (the CLI refuses a pixel compare);
134
+ that is expected for design mocks and is handled gracefully by the caller,
135
+ which falls back to the vision comparison.
136
+
137
+ A nonzero exit here means "differences found," not failure, so — unlike the
138
+ other commands — we do not raise on it; we parse the reported metrics.
139
+ """
140
+ if not is_available():
141
+ raise BrowserError(
142
+ f"`{_BINARY}` not found on PATH. Install it (see "
143
+ "sdk/design_verify/README.md)."
144
+ )
145
+ base = Path(baseline).expanduser().resolve()
146
+ if not base.exists():
147
+ raise BrowserError(f"baseline image not found: {base}")
148
+ out = Path(out_path).expanduser().resolve()
149
+ out.parent.mkdir(parents=True, exist_ok=True)
150
+
151
+ if url:
152
+ await _run("open", url)
153
+
154
+ proc = await asyncio.create_subprocess_exec(
155
+ _BINARY, "diff", "screenshot",
156
+ "--baseline", str(base),
157
+ "-o", str(out),
158
+ "-t", str(threshold),
159
+ stdout=asyncio.subprocess.PIPE,
160
+ stderr=asyncio.subprocess.PIPE,
161
+ )
162
+ try:
163
+ raw_out, raw_err = await asyncio.wait_for(
164
+ proc.communicate(), timeout=_CMD_TIMEOUT_S
165
+ )
166
+ except asyncio.TimeoutError as exc: # pragma: no cover - defensive
167
+ proc.kill()
168
+ raise BrowserError(f"`{_BINARY} diff screenshot` timed out") from exc
169
+
170
+ text = (raw_out.decode(errors="replace") + "\n" + raw_err.decode(errors="replace")).strip()
171
+ dim_mismatch = "dimension mismatch" in text.lower()
172
+ pct = None
173
+ px = None
174
+ if not dim_mismatch:
175
+ m = _PCT_RE.search(text)
176
+ if m:
177
+ pct = float(m.group(1))
178
+ m = _PIXELS_RE.search(text)
179
+ if m:
180
+ px = int(m.group(1).replace(",", ""))
181
+ return DiffResult(
182
+ mismatch_pct=pct,
183
+ diff_pixels=px,
184
+ diff_image=str(out) if out.exists() else None,
185
+ dimension_mismatch=dim_mismatch,
186
+ raw=text,
187
+ )
188
+
189
+
190
+ async def close() -> None:
191
+ """Close the active browser session (best-effort)."""
192
+ try:
193
+ await _run("close")
194
+ except BrowserError:
195
+ pass
@@ -0,0 +1,150 @@
1
+ """Vision comparison of a rendered UI against the original design.
2
+
3
+ Instead of adding an `anthropic`/`openai` dependency, this reuses the local
4
+ ``claude`` CLI (via :func:`sdk.local_exec.run_claude_local`). The CLI's built-in
5
+ ``Read`` tool reads image files natively, so we point it at BOTH the original
6
+ design screenshot and the freshly rendered one and ask it to produce a strict
7
+ JSON diff. This keeps the loop dependency-free and consistent with how the rest
8
+ of the SDK already shells out to the local agent.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+ from sdk.local_exec import run_claude_local
17
+
18
+ # Only Read is needed — the comparison must never edit files.
19
+ _COMPARE_TOOLS = ["Read"]
20
+
21
+
22
+ @dataclass
23
+ class Discrepancy:
24
+ area: str # e.g. "header", "primary CTA", "card grid spacing"
25
+ severity: str # "high" | "medium" | "low"
26
+ expected: str # what the design shows
27
+ actual: str # what the rendered UI shows
28
+
29
+
30
+ @dataclass
31
+ class ComparisonReport:
32
+ fidelity_score: int # 0-100, higher = closer to design
33
+ discrepancies: list[Discrepancy] = field(default_factory=list)
34
+ correction_prompt: str = "" # concrete fix instructions for the coder
35
+ raw: str = "" # raw model output (for debugging)
36
+
37
+ @property
38
+ def passed(self) -> bool:
39
+ return self.fidelity_score >= 0 # threshold applied by the loop, not here
40
+
41
+
42
+ def _build_compare_prompt(
43
+ design_path: Path,
44
+ rendered_path: Path,
45
+ diff_path: Path | None = None,
46
+ diff_note: str = "",
47
+ ) -> str:
48
+ """Prompt the local agent to diff two images and emit strict JSON only."""
49
+ diff_block = ""
50
+ if diff_path is not None:
51
+ diff_block = (
52
+ f" 3. PIXEL DIFF (changed regions highlighted in red): {diff_path}\n"
53
+ )
54
+ hint = ""
55
+ if diff_note:
56
+ hint = (
57
+ "\nA pixel-level comparison was already run: "
58
+ f"{diff_note} Read the PIXEL DIFF image to see WHERE the pixels "
59
+ "differ (red = changed), and use it to localize your review — but "
60
+ "judge semantically, not by raw pixel count (fonts/anti-aliasing/"
61
+ "placeholder content inflate raw pixel diffs).\n"
62
+ )
63
+ return (
64
+ "You are a meticulous UI design reviewer. Images are on disk:\n"
65
+ f" 1. TARGET DESIGN (the intended look): {design_path}\n"
66
+ f" 2. CURRENT RENDER (what our generated frontend actually looks like): "
67
+ f"{rendered_path}\n"
68
+ f"{diff_block}"
69
+ f"{hint}\n"
70
+ "Use the Read tool to open and view the images. Then compare the CURRENT "
71
+ "RENDER against the TARGET DESIGN across: overall layout & structure, "
72
+ "spacing/alignment, colors, typography (family/size/weight), component "
73
+ "presence and ordering, iconography, and content/labels.\n\n"
74
+ "Output ONLY a single JSON object (no prose, no markdown fences) with this "
75
+ "exact shape:\n"
76
+ "{\n"
77
+ ' "fidelity_score": <int 0-100, how closely the render matches the design>,\n'
78
+ ' "discrepancies": [\n'
79
+ ' {"area": "<region/component>", "severity": "high|medium|low", '
80
+ '"expected": "<what the design shows>", "actual": "<what the render shows>"}\n'
81
+ " ],\n"
82
+ ' "correction_prompt": "<a concise, imperative set of concrete code changes '
83
+ "that would close the gaps — reference components, colors (hex if visible), "
84
+ 'spacing, and layout explicitly so a coding agent can act on it directly>"\n'
85
+ "}\n\n"
86
+ "Rank discrepancies most-severe first. If the render already matches "
87
+ "closely, return a high score, an empty discrepancies list, and an empty "
88
+ "correction_prompt."
89
+ )
90
+
91
+
92
+ def _extract_json(text: str) -> dict:
93
+ """Pull the first balanced JSON object out of the model's output."""
94
+ start = text.find("{")
95
+ end = text.rfind("}")
96
+ if start == -1 or end == -1 or end <= start:
97
+ raise ValueError(f"no JSON object found in comparison output: {text[:200]!r}")
98
+ return json.loads(text[start : end + 1])
99
+
100
+
101
+ async def compare_design(
102
+ design_path: str | Path,
103
+ rendered_path: str | Path,
104
+ *,
105
+ work_dir: str | Path,
106
+ diff_path: str | Path | None = None,
107
+ diff_note: str = "",
108
+ ) -> ComparisonReport:
109
+ """Diff a rendered screenshot against the target design; return a report.
110
+
111
+ ``work_dir`` is the cwd handed to the local ``claude`` CLI — any readable dir
112
+ works; the image paths are passed absolute so location does not matter.
113
+
114
+ ``diff_path``/``diff_note`` optionally pass a pre-computed pixel-diff image
115
+ (from :func:`sdk.design_verify.browser.diff_baseline`) and its metrics, so
116
+ the vision model can localize its review to the highlighted regions.
117
+ """
118
+ design = Path(design_path).expanduser().resolve()
119
+ rendered = Path(rendered_path).expanduser().resolve()
120
+ for p in (design, rendered):
121
+ if not p.exists():
122
+ raise FileNotFoundError(f"image not found: {p}")
123
+ diff = Path(diff_path).expanduser().resolve() if diff_path else None
124
+ if diff is not None and not diff.exists():
125
+ diff = None # diff is a best-effort aid; never fail the review on it
126
+
127
+ result = await run_claude_local(
128
+ _build_compare_prompt(design, rendered, diff, diff_note),
129
+ str(work_dir),
130
+ allowed_tools=_COMPARE_TOOLS,
131
+ )
132
+ if not result.get("ok"):
133
+ raise RuntimeError(f"comparison run failed: {result.get('error')}")
134
+
135
+ raw = result.get("result", "") or ""
136
+ data = _extract_json(raw)
137
+ return ComparisonReport(
138
+ fidelity_score=int(data.get("fidelity_score", 0)),
139
+ discrepancies=[
140
+ Discrepancy(
141
+ area=str(d.get("area", "")),
142
+ severity=str(d.get("severity", "")),
143
+ expected=str(d.get("expected", "")),
144
+ actual=str(d.get("actual", "")),
145
+ )
146
+ for d in data.get("discrepancies", [])
147
+ ],
148
+ correction_prompt=str(data.get("correction_prompt", "")),
149
+ raw=raw,
150
+ )
@@ -0,0 +1,169 @@
1
+ """The closed refinement loop: render -> compare -> correct -> repeat.
2
+
3
+ This is the piece that turns a one-shot design-to-code generation into a
4
+ self-correcting one. It assumes the frontend has already been generated once and
5
+ is being served at ``preview_url`` (a local dev server). Each iteration:
6
+
7
+ 1. screenshot the live preview with agent-browser
8
+ 2. compare it against the target design (local claude vision diff)
9
+ 3. stop if fidelity >= threshold or we've run out of iterations
10
+ 4. otherwise feed the correction prompt back to the coding agent, which edits
11
+ the real files; then loop (the caller's dev server hot-reloads)
12
+
13
+ The coding step is injectable (``coder``) so the real pipeline can plug in its
14
+ session-resuming ``run_claude_local`` call (keeping context across iterations)
15
+ instead of the default fresh-run coder used here.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import time
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Awaitable, Callable
23
+
24
+ from sdk.local_exec import DEFAULT_ALLOWED_TOOLS, run_claude_local
25
+
26
+ from .browser import BrowserError, diff_baseline, render_screenshot
27
+ from .compare import ComparisonReport, compare_design
28
+
29
+ # A coder takes (project_path, correction_prompt) and applies fixes in place.
30
+ Coder = Callable[[str, str], Awaitable[None]]
31
+
32
+
33
+ @dataclass
34
+ class Iteration:
35
+ index: int
36
+ screenshot: str
37
+ fidelity_score: int
38
+ discrepancy_count: int
39
+
40
+
41
+ @dataclass
42
+ class RefinementResult:
43
+ passed: bool
44
+ iterations: list[Iteration] = field(default_factory=list)
45
+ final_report: ComparisonReport | None = None
46
+
47
+ @property
48
+ def best_score(self) -> int:
49
+ return max((it.fidelity_score for it in self.iterations), default=0)
50
+
51
+
52
+ async def _default_coder(project_path: str, correction_prompt: str) -> None:
53
+ """Fallback coder: a fresh local `claude` run that applies the corrections.
54
+
55
+ The real pipeline should pass a ``coder`` that RESUMES the ticket's session
56
+ (``resume_session_id=...``) so the agent keeps the design context across
57
+ iterations instead of starting cold each time.
58
+ """
59
+ prompt = (
60
+ "A visual review compared the current frontend against the target design "
61
+ "and found the differences below. Edit the code in this repository to "
62
+ "close them. Make the minimal, correct changes; do not restructure "
63
+ "unrelated code.\n\n"
64
+ f"{correction_prompt}"
65
+ )
66
+ result = await run_claude_local(
67
+ prompt, project_path, allowed_tools=DEFAULT_ALLOWED_TOOLS
68
+ )
69
+ if not result.get("ok"):
70
+ raise RuntimeError(f"correction run failed: {result.get('error')}")
71
+
72
+
73
+ async def verify_and_refine(
74
+ *,
75
+ project_path: str | Path,
76
+ design_path: str | Path,
77
+ preview_url: str,
78
+ artifact_dir: str | Path,
79
+ threshold: int = 90,
80
+ max_iterations: int = 3,
81
+ full_page: bool = False,
82
+ coder: Coder | None = None,
83
+ on_event: Callable[[str], None] | None = None,
84
+ ) -> RefinementResult:
85
+ """Iteratively refine the generated UI until it matches ``design_path``.
86
+
87
+ Args:
88
+ project_path: repo whose frontend files get edited on each correction.
89
+ design_path: the original design image (e.g. the JIRA screenshot).
90
+ preview_url: URL where the generated UI is being served (dev server).
91
+ artifact_dir: where per-iteration screenshots are written.
92
+ threshold: stop once fidelity_score >= this (0-100).
93
+ max_iterations: hard cap on correction rounds.
94
+ coder: applies corrections; defaults to a fresh local claude run.
95
+ on_event: optional progress sink (receives human-readable lines).
96
+
97
+ Returns a :class:`RefinementResult` with per-iteration scores and the final
98
+ comparison report.
99
+ """
100
+ project_path = str(project_path)
101
+ art = Path(artifact_dir).expanduser().resolve()
102
+ art.mkdir(parents=True, exist_ok=True)
103
+ coder = coder or _default_coder
104
+
105
+ def emit(msg: str) -> None:
106
+ if on_event:
107
+ on_event(msg)
108
+
109
+ result = RefinementResult(passed=False)
110
+ report: ComparisonReport | None = None
111
+
112
+ for i in range(1, max_iterations + 1):
113
+ shot = art / f"render_iter_{i}.png"
114
+ emit(f"[iter {i}/{max_iterations}] rendering {preview_url}")
115
+ await render_screenshot(preview_url, shot, full_page=full_page)
116
+
117
+ # Native pixel diff first — a fast, localizing signal + a highlight image
118
+ # the vision step can reason over. Best-effort: design mocks often differ
119
+ # in dimensions (CLI refuses the pixel compare), so never fail the loop
120
+ # on it — fall back to a pure vision comparison.
121
+ diff_path: str | None = None
122
+ diff_note = ""
123
+ try:
124
+ dr = await diff_baseline(
125
+ design_path, out_path=art / f"diff_iter_{i}.png"
126
+ )
127
+ if dr.dimension_mismatch:
128
+ diff_note = (
129
+ "the render and design differ in dimensions, so no pixel "
130
+ "diff was produced."
131
+ )
132
+ elif dr.mismatch_pct is not None:
133
+ diff_path = dr.diff_image
134
+ diff_note = f"the raw pixel mismatch is {dr.mismatch_pct:.1f}%."
135
+ except BrowserError as exc:
136
+ emit(f"[iter {i}/{max_iterations}] pixel diff skipped: {exc}")
137
+
138
+ emit(f"[iter {i}/{max_iterations}] comparing against design")
139
+ report = await compare_design(
140
+ design_path, shot, work_dir=art,
141
+ diff_path=diff_path, diff_note=diff_note,
142
+ )
143
+ result.iterations.append(
144
+ Iteration(
145
+ index=i,
146
+ screenshot=str(shot),
147
+ fidelity_score=report.fidelity_score,
148
+ discrepancy_count=len(report.discrepancies),
149
+ )
150
+ )
151
+ emit(
152
+ f"[iter {i}/{max_iterations}] fidelity={report.fidelity_score} "
153
+ f"discrepancies={len(report.discrepancies)}"
154
+ )
155
+
156
+ if report.fidelity_score >= threshold or not report.correction_prompt:
157
+ result.passed = report.fidelity_score >= threshold
158
+ break
159
+
160
+ if i == max_iterations:
161
+ break # scored but out of correction budget
162
+
163
+ emit(f"[iter {i}/{max_iterations}] applying corrections")
164
+ await coder(project_path, report.correction_prompt)
165
+ # Give a hot-reloading dev server a moment to rebuild before re-shooting.
166
+ time.sleep(2)
167
+
168
+ result.final_report = report
169
+ return result
@@ -1355,13 +1355,22 @@ async def _handle_story(
1355
1355
  # The Story is complete — nothing to convert or code in this run.
1356
1356
  return None
1357
1357
 
1358
- # STEP 1 — validate whether this is a CODEBASE (development) task: repo links /
1359
- # file paths in the instructions, or dev signals (PRs, branches, endpoints,
1360
- # deploy, …). A codebase task ALWAYS gets a technical PRD built from the current
1361
- # codebase; other domains (Finance, HR, …) get one only when explicitly asked.
1358
+ # STEP 1 — do we have CODEBASE UNDERSTANDING to ground a PRD in? That means an
1359
+ # actual repo target (a github/gitlab/bitbucket link in the instructions OR the
1360
+ # story), or dev work called for in the OPERATOR INSTRUCTIONS (PRs, branches,
1361
+ # endpoints, deploy, …).
1362
+ #
1363
+ # Deliberately NOT keyed off the story DESCRIPTION's vocabulary or a "PRD"
1364
+ # mention: a business/BA user story that merely contains technical words
1365
+ # (endpoint, API, database, "PRD", …) is NOT a codebase task. Keying off the
1366
+ # description used to false-positive such stories into PRD generation, which
1367
+ # folded an unwanted PRD into — and mangled — the operator's own document.
1362
1368
  has_repo = bool(_extract_repo_targets(human)) or bool(_extract_repo_targets(user_story))
1363
- is_codebase_task = has_repo or _is_dev_task(human) or _is_dev_task(user_story)
1364
- wants_prd = is_codebase_task or _wants_prd(human) or _wants_prd(user_story)
1369
+ is_codebase_task = has_repo or _is_dev_task(human)
1370
+ # PRD ONLY when we have codebase understanding. Without a repo/codebase, the
1371
+ # Story is converted to a Task with its description left intact (the whole,
1372
+ # proper document the operator provided) — never rewritten with a PRD.
1373
+ wants_prd = is_codebase_task
1365
1374
 
1366
1375
  if is_codebase_task:
1367
1376
  step = ("validated as a codebase task — building a technical PRD from the "