devops-bot-sdk 1.4.99__tar.gz → 1.4.101__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 (45) hide show
  1. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/devops_bot_sdk.egg-info/SOURCES.txt +2 -0
  4. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/pyproject.toml +1 -1
  5. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/__init__.py +2 -2
  6. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/client.py +22 -1
  7. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/design_verify/__init__.py +5 -0
  8. devops_bot_sdk-1.4.101/sdk/design_verify/assets.py +59 -0
  9. devops_bot_sdk-1.4.101/sdk/design_verify/bootstrap.py +170 -0
  10. devops_bot_sdk-1.4.101/sdk/design_verify/loop.py +204 -0
  11. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/ipc/handlers.py +65 -5
  12. devops_bot_sdk-1.4.99/sdk/design_verify/loop.py +0 -169
  13. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/README.md +0 -0
  14. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  15. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  16. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/devops_bot_sdk.egg-info/requires.txt +0 -0
  17. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  18. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/collectors/__init__.py +0 -0
  19. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/collectors/files.py +0 -0
  20. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/collectors/process.py +0 -0
  21. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/collectors/screenshot.py +0 -0
  22. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/config.py +0 -0
  23. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/crucial.py +0 -0
  24. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/design_verify/browser.py +0 -0
  25. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/design_verify/compare.py +0 -0
  26. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/exceptions.py +0 -0
  27. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/git_ops.py +0 -0
  28. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/graphify.py +0 -0
  29. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/hooks/__init__.py +0 -0
  30. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/hooks/crucial_guard.py +0 -0
  31. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/ipc/__init__.py +0 -0
  32. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/ipc/electron_bridge.py +0 -0
  33. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/local_exec.py +0 -0
  34. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/models/__init__.py +0 -0
  35. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/models/envelope.py +0 -0
  36. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/models/requests.py +0 -0
  37. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/models/responses.py +0 -0
  38. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/models/snapshots.py +0 -0
  39. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/py.typed +0 -0
  40. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/run_auto.py +0 -0
  41. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/sse.py +0 -0
  42. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/test.py +0 -0
  43. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/test_pipeline.py +0 -0
  44. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/sdk/updater.py +0 -0
  45. {devops_bot_sdk-1.4.99 → devops_bot_sdk-1.4.101}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.99
3
+ Version: 1.4.101
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.99
3
+ Version: 1.4.101
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
@@ -25,6 +25,8 @@ sdk/collectors/files.py
25
25
  sdk/collectors/process.py
26
26
  sdk/collectors/screenshot.py
27
27
  sdk/design_verify/__init__.py
28
+ sdk/design_verify/assets.py
29
+ sdk/design_verify/bootstrap.py
28
30
  sdk/design_verify/browser.py
29
31
  sdk/design_verify/compare.py
30
32
  sdk/design_verify/loop.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.99"
7
+ version = "1.4.101"
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.99
3
+ Version: 1.4.101
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.99"
33
+ __version__ = "1.4.101"
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.99"
61
+ SDK_VERSION = "1.4.101"
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
@@ -195,6 +195,27 @@ class BackendClient:
195
195
  _check_status(resp.status_code, self._base_url)
196
196
  return [ThreadSummary(**t) for t in resp.json().get("threads", [])]
197
197
 
198
+ # ── Design image (for local visual verification) ───────────────────
199
+
200
+ async def fetch_design_image(
201
+ self, jira_task_id: str,
202
+ ) -> tuple[bytes, str] | None:
203
+ """GET /api/v1/orchestrate/design-image/{jira_task_id}.
204
+
205
+ Returns the task's design screenshot as ``(bytes, mime_type)``, or
206
+ ``None`` when the task has no image attachment (backend 404). The backend
207
+ proxies the bytes from Jira (Atlassian auth lives there, not here). The
208
+ caller is responsible for treating the bytes as EPHEMERAL — see
209
+ ``sdk.design_verify.ephemeral_design_image``.
210
+ """
211
+ url = self._url(f"/api/v1/orchestrate/design-image/{jira_task_id}")
212
+ async with httpx.AsyncClient(timeout=30.0) as client:
213
+ resp = await client.get(url, headers=self._headers)
214
+ if resp.status_code == 404:
215
+ return None
216
+ _check_status(resp.status_code, self._base_url)
217
+ return resp.content, resp.headers.get("content-type", "image/png")
218
+
198
219
  # ── Orchestrator ──────────────────────────────────────────────────
199
220
 
200
221
  async def orchestrator_run(
@@ -34,6 +34,8 @@ argument.
34
34
  """
35
35
  from __future__ import annotations
36
36
 
37
+ from .assets import ephemeral_design_image
38
+ from .bootstrap import BootstrapResult, ensure_agent_browser
37
39
  from .browser import BrowserError, DiffResult, diff_baseline, render_screenshot, snapshot
38
40
  from .compare import ComparisonReport, compare_design
39
41
  from .loop import RefinementResult, verify_and_refine
@@ -48,4 +50,7 @@ __all__ = [
48
50
  "ComparisonReport",
49
51
  "verify_and_refine",
50
52
  "RefinementResult",
53
+ "ensure_agent_browser",
54
+ "BootstrapResult",
55
+ "ephemeral_design_image",
51
56
  ]
@@ -0,0 +1,59 @@
1
+ """Ephemeral handling of the design image fetched from the backend.
2
+
3
+ The JIRA design screenshot is downloaded from the backend proxy
4
+ (``BackendClient.fetch_design_image``) only for the duration of a verification
5
+ run and MUST NOT be persisted — same rule as the render/diff screenshots. This
6
+ module writes it to an OS temp file and deletes it on exit.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import os
12
+ import tempfile
13
+ from typing import AsyncIterator
14
+
15
+
16
+ def _ext_for_mime(mime: str) -> str:
17
+ m = (mime or "").lower()
18
+ if "png" in m:
19
+ return ".png"
20
+ if "jpeg" in m or "jpg" in m:
21
+ return ".jpg"
22
+ if "webp" in m:
23
+ return ".webp"
24
+ return ".img"
25
+
26
+
27
+ @contextlib.asynccontextmanager
28
+ async def ephemeral_design_image(client, jira_task_id: str) -> AsyncIterator[str | None]:
29
+ """Download a task's design image to a temp file for the duration of a `with`.
30
+
31
+ Yields the local path, or ``None`` when the task has no design image. The
32
+ file is deleted on exit no matter what — the design image is never persisted.
33
+
34
+ ``client`` is a ``BackendClient`` (anything exposing
35
+ ``async fetch_design_image(jira_task_id) -> (bytes, mime) | None``).
36
+
37
+ Example::
38
+
39
+ async with ephemeral_design_image(client, task_id) as design_path:
40
+ if design_path is None:
41
+ ... # no design attached — skip visual verification
42
+ else:
43
+ await verify_and_refine(design_path=design_path, ...)
44
+ """
45
+ img = await client.fetch_design_image(jira_task_id)
46
+ if not img:
47
+ yield None
48
+ return
49
+ data, mime = img
50
+ fd, path = tempfile.mkstemp(prefix="agentos_design_", suffix=_ext_for_mime(mime))
51
+ try:
52
+ with os.fdopen(fd, "wb") as f:
53
+ f.write(data)
54
+ yield path
55
+ finally:
56
+ try:
57
+ os.remove(path)
58
+ except OSError:
59
+ pass
@@ -0,0 +1,170 @@
1
+ """Per-user, on-demand installation of the ``agent-browser`` CLI.
2
+
3
+ Why this lives in the SDK
4
+ -------------------------
5
+ The visual verification loop renders and screenshots the generated frontend, and
6
+ that generation runs on the USER'S machine (the default `claude_code` engine runs
7
+ the local `claude` CLI locally — the backend only plans). So `agent-browser` must
8
+ be present on every user's system, not on the backend server.
9
+
10
+ `agent-browser` is a native Rust binary, not a pip package, so it cannot ride
11
+ along with ``pip install devops-bot-sdk``. Instead we bootstrap it lazily on
12
+ first use: check if it's already there, otherwise install it via the best channel
13
+ available on this machine, then fetch Chrome for Testing once. The whole thing is
14
+ idempotent and cached, and it NEVER hard-fails the SDK — if install isn't
15
+ possible (locked-down box, no package manager), it returns a reason and the
16
+ caller degrades to the text-only path.
17
+
18
+ Design choices
19
+ --------------
20
+ * **Lazy, not install-time.** A hard dependency at ``pip install`` time would
21
+ break every user on a machine without a Rust/Node toolchain or network. Lazy +
22
+ graceful-degrade keeps the SDK installable everywhere.
23
+ * **Cached.** A marker file under ``~/.agentos`` means the (subprocess-heavy)
24
+ checks run once, then it's a fast no-op on every subsequent run.
25
+ * **Opt-outable.** ``AGENTOS_ENABLE_DESIGN_VERIFY=0`` disables all of this.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import shutil
31
+ import subprocess
32
+ import sys
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+
36
+ _BINARY = "agent-browser"
37
+ _MARKER = Path.home() / ".agentos" / ".agent_browser_ready"
38
+ # Installs pull binaries / Chrome over the network — give them real headroom.
39
+ _INSTALL_TIMEOUT_S = 600.0
40
+ _QUICK_TIMEOUT_S = 60.0
41
+
42
+
43
+ @dataclass
44
+ class BootstrapResult:
45
+ ok: bool
46
+ path: str | None # resolved agent-browser path when ok
47
+ reason: str # human-readable outcome (why it failed, if not ok)
48
+ installed: bool = False # True if we actually installed something this call
49
+
50
+
51
+ def _run(cmd: list[str], timeout: float) -> tuple[bool, str]:
52
+ """Run a setup command; return (success, combined-output). Never raises."""
53
+ try:
54
+ proc = subprocess.run(
55
+ cmd, capture_output=True, text=True, timeout=timeout,
56
+ )
57
+ except FileNotFoundError:
58
+ return False, f"{cmd[0]} not found"
59
+ except subprocess.TimeoutExpired:
60
+ return False, f"{' '.join(cmd)} timed out after {timeout:.0f}s"
61
+ except Exception as exc: # noqa: BLE001 — setup must never crash the SDK
62
+ return False, str(exc)
63
+ out = (proc.stdout or "") + (proc.stderr or "")
64
+ return proc.returncode == 0, out.strip()
65
+
66
+
67
+ def _disabled() -> bool:
68
+ return os.getenv("AGENTOS_ENABLE_DESIGN_VERIFY", "1").lower() in ("0", "false", "no")
69
+
70
+
71
+ def _install_binary() -> tuple[bool, str]:
72
+ """Install the agent-browser binary via the best available channel.
73
+
74
+ Order: npm (Node is almost always present — it's how the `claude` CLI itself
75
+ is installed), then Homebrew on macOS, then Cargo. Each is tried only if its
76
+ package manager exists. Prebuilt-binary download is intentionally omitted here
77
+ (GitHub release asset plumbing) — document it as the manual fallback.
78
+ """
79
+ attempts: list[str] = []
80
+
81
+ if shutil.which("npm"):
82
+ ok, out = _run(["npm", "install", "-g", _BINARY], _INSTALL_TIMEOUT_S)
83
+ if ok and shutil.which(_BINARY):
84
+ return True, "installed via npm"
85
+ attempts.append(f"npm: {out[-200:]}")
86
+
87
+ if sys.platform == "darwin" and shutil.which("brew"):
88
+ ok, out = _run(["brew", "install", _BINARY], _INSTALL_TIMEOUT_S)
89
+ if ok and shutil.which(_BINARY):
90
+ return True, "installed via brew"
91
+ attempts.append(f"brew: {out[-200:]}")
92
+
93
+ if shutil.which("cargo"):
94
+ ok, out = _run(["cargo", "install", _BINARY], _INSTALL_TIMEOUT_S)
95
+ if ok and shutil.which(_BINARY):
96
+ return True, "installed via cargo"
97
+ attempts.append(f"cargo: {out[-200:]}")
98
+
99
+ if not attempts:
100
+ return False, (
101
+ "no supported installer found (need npm, brew, or cargo). Install "
102
+ f"`{_BINARY}` manually — see sdk/design_verify/README.md."
103
+ )
104
+ return False, "install attempts failed -> " + " | ".join(attempts)
105
+
106
+
107
+ def _ensure_chrome() -> tuple[bool, str]:
108
+ """Ensure Chrome for Testing is present (`agent-browser install`)."""
109
+ # `doctor` reports daemon/browser state; if Chrome is already there this is fast.
110
+ ok, out = _run([_BINARY, "doctor"], _QUICK_TIMEOUT_S)
111
+ if ok and "chrome" in out.lower() and "not" not in out.lower():
112
+ return True, "chrome already present"
113
+ cmd = [_BINARY, "install"]
114
+ if sys.platform.startswith("linux"):
115
+ cmd.append("--with-deps") # pull system libs Chrome needs on Linux
116
+ ok, out = _run(cmd, _INSTALL_TIMEOUT_S)
117
+ return ok, out[-200:] if out else ("chrome installed" if ok else "chrome install failed")
118
+
119
+
120
+ def ensure_agent_browser(*, force: bool = False) -> BootstrapResult:
121
+ """Make ``agent-browser`` usable on this machine; idempotent and cached.
122
+
123
+ Returns a :class:`BootstrapResult`. On success ``path`` is the binary location.
124
+ Never raises — callers gate the verification loop on ``result.ok``.
125
+ """
126
+ if _disabled():
127
+ return BootstrapResult(False, None, "design verification disabled "
128
+ "(AGENTOS_ENABLE_DESIGN_VERIFY=0)")
129
+
130
+ existing = shutil.which(_BINARY)
131
+ if existing and _MARKER.exists() and not force:
132
+ return BootstrapResult(True, existing, "already bootstrapped (cached)")
133
+
134
+ installed = False
135
+ if not existing:
136
+ ok, reason = _install_binary()
137
+ if not ok:
138
+ return BootstrapResult(False, None, reason)
139
+ installed = True
140
+ existing = shutil.which(_BINARY)
141
+
142
+ chrome_ok, chrome_reason = _ensure_chrome()
143
+ if not chrome_ok:
144
+ return BootstrapResult(False, existing,
145
+ f"agent-browser installed but Chrome setup failed: {chrome_reason}",
146
+ installed=installed)
147
+
148
+ try:
149
+ _MARKER.parent.mkdir(parents=True, exist_ok=True)
150
+ _MARKER.write_text("ok")
151
+ except OSError:
152
+ pass # marker is an optimization; not being able to write it is harmless
153
+
154
+ return BootstrapResult(True, existing, "ready", installed=installed)
155
+
156
+
157
+ def main() -> int:
158
+ """CLI entry point: ``python -m sdk.design_verify.bootstrap``.
159
+
160
+ Lets ops/onboarding provision agent-browser for a user ahead of time instead
161
+ of waiting for the first design ticket.
162
+ """
163
+ res = ensure_agent_browser(force="--force" in sys.argv)
164
+ print(f"agent-browser: {'OK' if res.ok else 'UNAVAILABLE'} — {res.reason}"
165
+ + (f" ({res.path})" if res.path else ""))
166
+ return 0 if res.ok else 1
167
+
168
+
169
+ if __name__ == "__main__":
170
+ raise SystemExit(main())
@@ -0,0 +1,204 @@
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 asyncio
20
+ import shutil
21
+ import tempfile
22
+ import time
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from typing import Awaitable, Callable
26
+
27
+ from sdk.local_exec import DEFAULT_ALLOWED_TOOLS, run_claude_local
28
+
29
+ from .bootstrap import ensure_agent_browser
30
+ from .browser import BrowserError, diff_baseline, render_screenshot
31
+ from .compare import ComparisonReport, compare_design
32
+
33
+ # A coder takes (project_path, correction_prompt) and applies fixes in place.
34
+ Coder = Callable[[str, str], Awaitable[None]]
35
+
36
+
37
+ @dataclass
38
+ class Iteration:
39
+ index: int
40
+ screenshot: str
41
+ fidelity_score: int
42
+ discrepancy_count: int
43
+
44
+
45
+ @dataclass
46
+ class RefinementResult:
47
+ passed: bool
48
+ iterations: list[Iteration] = field(default_factory=list)
49
+ final_report: ComparisonReport | None = None
50
+
51
+ @property
52
+ def best_score(self) -> int:
53
+ return max((it.fidelity_score for it in self.iterations), default=0)
54
+
55
+
56
+ async def _default_coder(project_path: str, correction_prompt: str) -> None:
57
+ """Fallback coder: a fresh local `claude` run that applies the corrections.
58
+
59
+ The real pipeline should pass a ``coder`` that RESUMES the ticket's session
60
+ (``resume_session_id=...``) so the agent keeps the design context across
61
+ iterations instead of starting cold each time.
62
+ """
63
+ prompt = (
64
+ "A visual review compared the current frontend against the target design "
65
+ "and found the differences below. Edit the code in this repository to "
66
+ "close them. Make the minimal, correct changes; do not restructure "
67
+ "unrelated code.\n\n"
68
+ f"{correction_prompt}"
69
+ )
70
+ result = await run_claude_local(
71
+ prompt, project_path, allowed_tools=DEFAULT_ALLOWED_TOOLS
72
+ )
73
+ if not result.get("ok"):
74
+ raise RuntimeError(f"correction run failed: {result.get('error')}")
75
+
76
+
77
+ async def verify_and_refine(
78
+ *,
79
+ project_path: str | Path,
80
+ design_path: str | Path,
81
+ preview_url: str,
82
+ artifact_dir: str | Path | None = None,
83
+ cleanup: bool = True,
84
+ threshold: int = 90,
85
+ max_iterations: int = 3,
86
+ full_page: bool = False,
87
+ coder: Coder | None = None,
88
+ on_event: Callable[[str], None] | None = None,
89
+ ) -> RefinementResult:
90
+ """Iteratively refine the generated UI until it matches ``design_path``.
91
+
92
+ Args:
93
+ project_path: repo whose frontend files get edited on each correction.
94
+ design_path: the original design image (e.g. the JIRA screenshot).
95
+ preview_url: URL where the generated UI is being served (dev server).
96
+ artifact_dir: scratch dir for per-iteration screenshots. **Optional** — if
97
+ omitted, an OS temp dir is created and (by default) deleted
98
+ when the run ends. Screenshots are working artifacts, NOT
99
+ permanent storage.
100
+ cleanup: delete the screenshots/diffs when the run ends (default
101
+ True). Set False only for debugging. Note: when True, the
102
+ screenshot paths in the returned result no longer exist.
103
+ threshold: stop once fidelity_score >= this (0-100).
104
+ max_iterations: hard cap on correction rounds.
105
+ coder: applies corrections; defaults to a fresh local claude run.
106
+ on_event: optional progress sink (receives human-readable lines).
107
+
108
+ Returns a :class:`RefinementResult` with per-iteration scores and the final
109
+ comparison report.
110
+ """
111
+ project_path = str(project_path)
112
+ # Screenshots are ephemeral working files. Default to a private OS temp dir
113
+ # that we remove in the `finally` below — nothing is persisted unless the
114
+ # caller both passes an artifact_dir AND sets cleanup=False.
115
+ if artifact_dir is None:
116
+ art = Path(tempfile.mkdtemp(prefix="agentos_design_verify_"))
117
+ else:
118
+ art = Path(artifact_dir).expanduser().resolve()
119
+ art.mkdir(parents=True, exist_ok=True)
120
+ coder = coder or _default_coder
121
+
122
+ def emit(msg: str) -> None:
123
+ if on_event:
124
+ on_event(msg)
125
+
126
+ result = RefinementResult(passed=False)
127
+ report: ComparisonReport | None = None
128
+
129
+ # try/finally spans everything after the scratch dir exists, so the dir is
130
+ # always removed — even if bootstrap or the first render raises.
131
+ try:
132
+ # Provision agent-browser for THIS user on first use (idempotent, cached).
133
+ # The loop can't render without it, so this is a hard prerequisite — but
134
+ # the bootstrap never raises; we surface a clear, actionable error.
135
+ emit("ensuring agent-browser is installed")
136
+ boot = await asyncio.to_thread(ensure_agent_browser)
137
+ if not boot.ok:
138
+ raise BrowserError(f"agent-browser unavailable: {boot.reason}")
139
+ if boot.installed:
140
+ emit(f"agent-browser provisioned: {boot.reason}")
141
+
142
+ for i in range(1, max_iterations + 1):
143
+ shot = art / f"render_iter_{i}.png"
144
+ emit(f"[iter {i}/{max_iterations}] rendering {preview_url}")
145
+ await render_screenshot(preview_url, shot, full_page=full_page)
146
+
147
+ # Native pixel diff first — a fast, localizing signal + a highlight
148
+ # image the vision step can reason over. Best-effort: design mocks
149
+ # often differ in dimensions (CLI refuses the pixel compare), so never
150
+ # fail the loop on it — fall back to a pure vision comparison.
151
+ diff_path: str | None = None
152
+ diff_note = ""
153
+ try:
154
+ dr = await diff_baseline(
155
+ design_path, out_path=art / f"diff_iter_{i}.png"
156
+ )
157
+ if dr.dimension_mismatch:
158
+ diff_note = (
159
+ "the render and design differ in dimensions, so no pixel "
160
+ "diff was produced."
161
+ )
162
+ elif dr.mismatch_pct is not None:
163
+ diff_path = dr.diff_image
164
+ diff_note = f"the raw pixel mismatch is {dr.mismatch_pct:.1f}%."
165
+ except BrowserError as exc:
166
+ emit(f"[iter {i}/{max_iterations}] pixel diff skipped: {exc}")
167
+
168
+ emit(f"[iter {i}/{max_iterations}] comparing against design")
169
+ report = await compare_design(
170
+ design_path, shot, work_dir=art,
171
+ diff_path=diff_path, diff_note=diff_note,
172
+ )
173
+ result.iterations.append(
174
+ Iteration(
175
+ index=i,
176
+ screenshot=str(shot),
177
+ fidelity_score=report.fidelity_score,
178
+ discrepancy_count=len(report.discrepancies),
179
+ )
180
+ )
181
+ emit(
182
+ f"[iter {i}/{max_iterations}] fidelity={report.fidelity_score} "
183
+ f"discrepancies={len(report.discrepancies)}"
184
+ )
185
+
186
+ if report.fidelity_score >= threshold or not report.correction_prompt:
187
+ result.passed = report.fidelity_score >= threshold
188
+ break
189
+
190
+ if i == max_iterations:
191
+ break # scored but out of correction budget
192
+
193
+ emit(f"[iter {i}/{max_iterations}] applying corrections")
194
+ await coder(project_path, report.correction_prompt)
195
+ # Give a hot-reloading dev server a moment to rebuild before re-shooting.
196
+ time.sleep(2)
197
+ finally:
198
+ # Screenshots/diffs are working artifacts, never permanent storage.
199
+ if cleanup:
200
+ shutil.rmtree(art, ignore_errors=True)
201
+ emit("cleaned up screenshots")
202
+
203
+ result.final_report = report
204
+ return result
@@ -162,6 +162,7 @@ def _build_local_prompt(
162
162
  repo_name: str | None = None,
163
163
  grounded: bool = False,
164
164
  branch_convention: str | None = None,
165
+ design_image_path: str | None = None,
165
166
  ) -> str:
166
167
  """Compose the coding prompt for the local claude CLI from the server plan.
167
168
 
@@ -200,6 +201,21 @@ def _build_local_prompt(
200
201
  "code actually is, trust the code and the operator instructions."
201
202
  )
202
203
  parts.append(scope_line)
204
+ # 0.5 Design reference — when the Jira ticket has a design screenshot it is the
205
+ # authoritative VISUAL spec. Placed near the top so the model attends to it;
206
+ # the local claude agent's Read tool views images natively, so it can code
207
+ # against the real design instead of blind from the text description.
208
+ if design_image_path:
209
+ parts.append(
210
+ "=== Design reference (AUTHORITATIVE visual spec) ===\n"
211
+ f"A design screenshot for this task is attached at: {design_image_path}\n"
212
+ "Use the Read tool to VIEW this image BEFORE writing any UI code, and "
213
+ "match it precisely: layout & structure, spacing & alignment, colors, "
214
+ "typography (family/size/weight), component choices, and iconography. "
215
+ "When the image and the text description conflict, the image wins for "
216
+ "anything visual. Treat visual fidelity to this design as a required "
217
+ "acceptance criterion."
218
+ )
203
219
  # 1. Operator instructions — FIRST, verbatim, authoritative.
204
220
  if human_instructions and human_instructions.strip():
205
221
  parts.append(
@@ -1648,6 +1664,35 @@ def _tool_detail(tool_name: str, inp: dict) -> str:
1648
1664
  return ""
1649
1665
 
1650
1666
 
1667
+ async def _download_design_image(client: "BackendClient", task_id: str) -> str | None:
1668
+ """Best-effort: download the ticket's design screenshot to a temp file.
1669
+
1670
+ The local claude agent Reads this path to code against the real design (not
1671
+ blind from text). Returns the temp path or None (no image / any failure).
1672
+ EPHEMERAL — the caller MUST delete it after the run; it is never persisted.
1673
+ """
1674
+ if not task_id:
1675
+ return None
1676
+ try:
1677
+ img = await client.fetch_design_image(task_id)
1678
+ except Exception: # noqa: BLE001 — visual grounding is best-effort
1679
+ return None
1680
+ if not img:
1681
+ return None
1682
+ data, mime = img
1683
+ m = (mime or "").lower()
1684
+ ext = ".png" if "png" in m else (".jpg" if ("jpeg" in m or "jpg" in m) else ".img")
1685
+ try:
1686
+ import os
1687
+ import tempfile
1688
+ fd, path = tempfile.mkstemp(prefix="agentos_design_", suffix=ext)
1689
+ with os.fdopen(fd, "wb") as f:
1690
+ f.write(data)
1691
+ return path
1692
+ except OSError:
1693
+ return None
1694
+
1695
+
1651
1696
  async def _code_one_repo(
1652
1697
  client: BackendClient,
1653
1698
  *,
@@ -1874,21 +1919,36 @@ async def _code_one_repo(
1874
1919
  # When recon ran, the implementation resumes that session so its grounded plan
1875
1920
  # (and the technical PRD, if generated) stays in context; if a PRD is present the
1876
1921
  # prompt makes it the authoritative spec.
1922
+ # Design-aware generation: if the ticket has a design screenshot, download it
1923
+ # (ephemeral) so the local agent can Read it while coding. Consumed by the
1924
+ # FIRST run below — after that it's in the session context, so recovery/repair
1925
+ # resumes don't need the file. Deleted in `finally` regardless of outcome.
1926
+ design_path = await _download_design_image(client, task_id)
1927
+
1877
1928
  prompt = _build_local_prompt(
1878
1929
  pending["user_input"], pending.get("structured_plan"),
1879
1930
  pending.get("human_instructions"),
1880
1931
  agent_mode=agent_mode, git_managed=bool(branch_info),
1881
1932
  repo_name=name, grounded=bool(recon_session), branch_convention=branch_name,
1933
+ design_image_path=design_path,
1882
1934
  )
1883
1935
 
1884
1936
  # 3b. initial claude run. resume_session_id (rate-limit/context retry) wins;
1885
1937
  # otherwise resume the recon session so implementation builds on the recon
1886
1938
  # exploration instead of starting cold.
1887
- result = await local_exec.run_claude_local(
1888
- prompt, repo_path, on_event=_on_event,
1889
- github_token=gh_token, approval_dir=approval_dir,
1890
- resume_session_id=resume_session_id or recon_session or None,
1891
- )
1939
+ try:
1940
+ result = await local_exec.run_claude_local(
1941
+ prompt, repo_path, on_event=_on_event,
1942
+ github_token=gh_token, approval_dir=approval_dir,
1943
+ resume_session_id=resume_session_id or recon_session or None,
1944
+ )
1945
+ finally:
1946
+ if design_path:
1947
+ import os
1948
+ try:
1949
+ os.remove(design_path)
1950
+ except OSError:
1951
+ pass
1892
1952
 
1893
1953
  # 4. context-window recovery
1894
1954
  import time as _time
@@ -1,169 +0,0 @@
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