refactorai-cli 0.3.3__tar.gz → 0.3.5__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 (35) hide show
  1. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/PKG-INFO +3 -3
  2. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/README.md +1 -1
  3. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/pyproject.toml +2 -2
  4. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/__init__.py +1 -1
  5. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/client.py +18 -0
  6. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/cloud_rr.py +201 -2
  7. refactorai_cli-0.3.5/refactorai_cli/commands/account_cmds.py +76 -0
  8. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/commands/auth_cmds.py +3 -0
  9. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/commands/run_cmds.py +257 -9
  10. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/commands/runtime_proxy_cmds.py +7 -0
  11. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/main.py +47 -0
  12. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli.egg-info/PKG-INFO +3 -3
  13. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli.egg-info/SOURCES.txt +1 -0
  14. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli.egg-info/requires.txt +1 -1
  15. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/auth.py +0 -0
  16. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/commands/__init__.py +0 -0
  17. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/commands/cloud_cmds.py +0 -0
  18. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/commands/engine_cmds.py +0 -0
  19. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/commands/model_cmds.py +0 -0
  20. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/commands/rules_cmds.py +0 -0
  21. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/commands/runtime_cmds.py +0 -0
  22. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/commands/setup_cmds.py +0 -0
  23. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/control_plane.py +0 -0
  24. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/credentials.py +0 -0
  25. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/local_constitution.py +0 -0
  26. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/local_engine_runtime.py +0 -0
  27. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/local_paths.py +0 -0
  28. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/model_policy.py +0 -0
  29. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/runtime_manager.py +0 -0
  30. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/settings.py +0 -0
  31. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli/setup_flow.py +0 -0
  32. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli.egg-info/dependency_links.txt +0 -0
  33. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli.egg-info/entry_points.txt +0 -0
  34. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/refactorai_cli.egg-info/top_level.txt +0 -0
  35. {refactorai_cli-0.3.3 → refactorai_cli-0.3.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.3.3
3
+ Version: 0.3.5
4
4
  Summary: Local-first CLI for the refactor platform
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -8,7 +8,7 @@ Requires-Dist: typer>=0.12.0
8
8
  Requires-Dist: httpx>=0.27.0
9
9
  Requires-Dist: rich>=13.7.0
10
10
  Requires-Dist: PyYAML>=6.0.1
11
- Requires-Dist: refactorai-core>=3.0.3
11
+ Requires-Dist: refactorai-core>=3.0.4
12
12
 
13
13
  # refactorai-cli
14
14
 
@@ -54,7 +54,7 @@ python -m twine upload ./refactorai-cli/dist/*
54
54
  ## Install test (local)
55
55
 
56
56
  ```bash
57
- python -m pip install ./refactorai-cli/dist/refactorai_cli-0.3.3-py3-none-any.whl
57
+ python -m pip install ./refactorai-cli/dist/refactorai_cli-0.3.4-py3-none-any.whl
58
58
  refactor --version
59
59
  ```
60
60
 
@@ -42,7 +42,7 @@ python -m twine upload ./refactorai-cli/dist/*
42
42
  ## Install test (local)
43
43
 
44
44
  ```bash
45
- python -m pip install ./refactorai-cli/dist/refactorai_cli-0.3.3-py3-none-any.whl
45
+ python -m pip install ./refactorai-cli/dist/refactorai_cli-0.3.4-py3-none-any.whl
46
46
  refactor --version
47
47
  ```
48
48
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "refactorai-cli"
3
- version = "0.3.3"
3
+ version = "0.3.5"
4
4
  description = "Local-first CLI for the refactor platform"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -12,7 +12,7 @@ dependencies = [
12
12
  "httpx>=0.27.0",
13
13
  "rich>=13.7.0",
14
14
  "PyYAML>=6.0.1",
15
- "refactorai-core>=3.0.3",
15
+ "refactorai-core>=3.0.4",
16
16
  ]
17
17
 
18
18
  [project.scripts]
@@ -5,4 +5,4 @@ the shared `refactor_core` pipeline from a project folder while staying
5
5
  authenticated to the hosted platform via a developer key.
6
6
  """
7
7
 
8
- __version__ = "0.3.3"
8
+ __version__ = "0.3.5"
@@ -44,3 +44,21 @@ class PlatformClient:
44
44
  f"Key validation failed ({response.status_code})", status_code=response.status_code
45
45
  )
46
46
  return response.json()
47
+
48
+ def list_key_accounts(self, developer_key: str) -> list[dict]:
49
+ try:
50
+ response = httpx.get(
51
+ f"{self.base_url}/v1/keys/accounts",
52
+ headers={"Authorization": f"Bearer {developer_key}"},
53
+ timeout=self.timeout,
54
+ )
55
+ except httpx.HTTPError as exc:
56
+ raise PlatformError(f"Could not reach platform at {self.base_url}: {exc}") from exc
57
+ if response.status_code == 401:
58
+ raise PlatformError("Developer key is invalid or revoked", status_code=401)
59
+ if response.status_code >= 400:
60
+ raise PlatformError(
61
+ f"Account discovery failed ({response.status_code})", status_code=response.status_code
62
+ )
63
+ payload = response.json()
64
+ return payload if isinstance(payload, list) else []
@@ -23,6 +23,9 @@ this design fixes. ``tests/test_cloud_rr_client_boundary.py`` enforces this.
23
23
  from __future__ import annotations
24
24
 
25
25
  import hashlib
26
+ import re
27
+ import shlex
28
+ import shutil
26
29
  import subprocess
27
30
  from pathlib import Path
28
31
  from typing import Callable
@@ -48,11 +51,33 @@ from refactor_core.refactor_requests import (
48
51
  save_request,
49
52
  )
50
53
  from refactor_core.store import create_run
51
- from refactor_core.testcmd import resolve_test_command
54
+ from refactor_core.testcmd import _SKIP_DIRS, discover_test_command, resolve_test_command
52
55
 
53
56
  from refactorai_cli.auth import ensure_authenticated
54
57
  from refactorai_cli.settings import platform_url
55
58
 
59
+ _ENV_ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*$")
60
+
61
+ # Manifest/config files that reveal how a project runs its tests. Sent (truncated)
62
+ # to the server for agentic test-command discovery when deterministic detection
63
+ # finds nothing.
64
+ _MANIFEST_NAMES = (
65
+ "package.json",
66
+ "pyproject.toml",
67
+ "setup.cfg",
68
+ "setup.py",
69
+ "pytest.ini",
70
+ "tox.ini",
71
+ "noxfile.py",
72
+ "requirements.txt",
73
+ "Makefile",
74
+ "makefile",
75
+ "go.mod",
76
+ "Cargo.toml",
77
+ "build.gradle",
78
+ "pom.xml",
79
+ )
80
+
56
81
 
57
82
  class CloudRRError(RuntimeError):
58
83
  """Raised when the cloud RR session cannot proceed."""
@@ -98,6 +123,170 @@ def _run_test(project_root: Path, test_command: str | None) -> tuple[bool, str]:
98
123
  return False, (proc.stdout or proc.stderr or "")[-500:]
99
124
 
100
125
 
126
+ def _client_preflight_command(project_root: Path, command: str | None) -> bool:
127
+ """Return True when ``command`` looks runnable on this host.
128
+
129
+ Composite/shell commands (``&&``, subshells, pipes) run under ``shell=True``
130
+ and can't be reduced to one binary, so we trust them and let the real exit
131
+ code gate. Simple commands are validated by checking the leading binary is on
132
+ PATH — this guards against running (and charging for) a server-suggested
133
+ command whose toolchain isn't installed.
134
+ """
135
+ if not command or not str(command).strip():
136
+ return False
137
+ if any(op in command for op in ("&&", "||", ";", "|", "(", ")")):
138
+ return True
139
+ try:
140
+ tokens = shlex.split(command, posix=True)
141
+ except ValueError:
142
+ return False
143
+ while tokens and _ENV_ASSIGN_RE.match(tokens[0]):
144
+ tokens.pop(0)
145
+ if not tokens:
146
+ return False
147
+ binary = tokens[0]
148
+ if "/" in binary:
149
+ return Path(binary).expanduser().exists()
150
+ return shutil.which(binary) is not None
151
+
152
+
153
+ def _collect_test_manifests(
154
+ project_root: Path, *, max_files: int = 24, max_bytes: int = 4000
155
+ ) -> list[dict]:
156
+ """Gather manifest snippets (repo root + immediate subdirs) for discovery."""
157
+ root = Path(project_root)
158
+ docs: list[dict] = []
159
+ dirs: list[Path] = [root]
160
+ try:
161
+ for child in sorted(root.iterdir(), key=lambda p: p.name):
162
+ if child.is_dir() and child.name not in _SKIP_DIRS and not child.name.startswith("."):
163
+ dirs.append(child)
164
+ except OSError:
165
+ return docs
166
+
167
+ for directory in dirs:
168
+ for name in _MANIFEST_NAMES:
169
+ path = directory / name
170
+ if not path.is_file():
171
+ continue
172
+ try:
173
+ content = path.read_text(encoding="utf-8", errors="replace")[:max_bytes]
174
+ except OSError:
175
+ continue
176
+ rel = path.relative_to(root)
177
+ docs.append({"path": str(rel), "content": content})
178
+ if len(docs) >= max_files:
179
+ return docs
180
+ tests_dir = directory / "tests"
181
+ if tests_dir.is_dir():
182
+ try:
183
+ names = sorted(p.name for p in tests_dir.iterdir() if p.is_file())[:40]
184
+ except OSError:
185
+ names = []
186
+ rel = tests_dir.relative_to(root)
187
+ docs.append({"path": f"{rel}/ (listing)", "content": "\n".join(names)})
188
+ if len(docs) >= max_files:
189
+ return docs
190
+ return docs
191
+
192
+
193
+ def _discover_test_command_via_cloud(
194
+ *,
195
+ project_root: Path,
196
+ constitution,
197
+ execution_mode: str,
198
+ auth_ctx,
199
+ model_hint: str | None,
200
+ apply_byok_payload: Callable[[dict, object], None] | None,
201
+ ) -> str:
202
+ """Ask the server to infer a test command from the project's manifests.
203
+
204
+ Best-effort: any transport/parse/auth issue returns ``""`` so the caller
205
+ falls back to the existing defer-when-unverifiable behavior.
206
+ """
207
+ documents = _collect_test_manifests(project_root)
208
+ if not documents:
209
+ return ""
210
+ payload = {
211
+ "stage": "rr_discover_testcmd",
212
+ "constitution_hash": constitution.content_hash,
213
+ "constitution_body": getattr(constitution, "body", "") or "",
214
+ "model_hint": model_hint,
215
+ "documents": documents,
216
+ "execution_mode": execution_mode,
217
+ }
218
+ if execution_mode == "cloud_byok" and apply_byok_payload is not None:
219
+ apply_byok_payload(payload, constitution)
220
+ idem = hashlib.sha256(
221
+ "|".join(
222
+ [auth_ctx.account_id, execution_mode, constitution.content_hash, "rr_discover_testcmd"]
223
+ ).encode("utf-8")
224
+ ).hexdigest()
225
+ try:
226
+ resp = httpx.post(
227
+ f"{platform_url()}/v1/inference/refactor",
228
+ headers={
229
+ "Authorization": f"Bearer {auth_ctx.key.key}",
230
+ "Content-Type": "application/json",
231
+ "Idempotency-Key": idem,
232
+ },
233
+ json=payload,
234
+ timeout=45.0,
235
+ )
236
+ except httpx.HTTPError:
237
+ return ""
238
+ if resp.status_code != 200:
239
+ return ""
240
+ try:
241
+ data = resp.json() if resp.content else {}
242
+ except ValueError:
243
+ return ""
244
+ return str((data or {}).get("test_command", "") or "").strip()
245
+
246
+
247
+ def _resolve_cloud_test_command(
248
+ *,
249
+ project_root: Path,
250
+ config: dict,
251
+ constitution,
252
+ execution_mode: str,
253
+ auth_ctx,
254
+ model_hint: str | None,
255
+ apply_byok_payload: Callable[[dict, object], None] | None,
256
+ ) -> tuple[str | None, str]:
257
+ """Resolve a test command for a cloud run; return ``(command, note)``.
258
+
259
+ Layered resolution so the developer usually needs no config:
260
+ 1. configured ``verification.tests.command`` or root-marker detection,
261
+ 2. deterministic monorepo discovery (subproject test setups),
262
+ 3. agentic discovery (server infers from manifests), preflight-validated.
263
+ """
264
+ configured = resolve_test_command(project_root, config)
265
+ if configured:
266
+ return configured, ""
267
+
268
+ discovered = discover_test_command(project_root)
269
+ if discovered:
270
+ return discovered, f"auto-detected test command: {discovered}"
271
+
272
+ suggested = _discover_test_command_via_cloud(
273
+ project_root=project_root,
274
+ constitution=constitution,
275
+ execution_mode=execution_mode,
276
+ auth_ctx=auth_ctx,
277
+ model_hint=model_hint,
278
+ apply_byok_payload=apply_byok_payload,
279
+ )
280
+ if suggested and _client_preflight_command(project_root, suggested):
281
+ return suggested, f"server-suggested test command: {suggested}"
282
+ if suggested:
283
+ return None, (
284
+ f"server suggested test command '{suggested}' but its toolchain was not "
285
+ "found on PATH; skipped."
286
+ )
287
+ return None, ""
288
+
289
+
101
290
  class _Session:
102
291
  """Transport for the RR session endpoints with status-code error mapping."""
103
292
 
@@ -200,7 +389,15 @@ def run_cloud_refactor_requests(
200
389
 
201
390
  execution_mode = str(constitution.get_setting("mode", "cloud_managed") or "cloud_managed")
202
391
  auth_ctx = ensure_authenticated(project_root)
203
- test_command = resolve_test_command(project_root, config)
392
+ test_command, testcmd_note = _resolve_cloud_test_command(
393
+ project_root=project_root,
394
+ config=config,
395
+ constitution=constitution,
396
+ execution_mode=execution_mode,
397
+ auth_ctx=auth_ctx,
398
+ model_hint=model_hint,
399
+ apply_byok_payload=apply_byok_payload,
400
+ )
204
401
 
205
402
  if requests is None:
206
403
  requests = pending_requests(project_root)
@@ -217,6 +414,8 @@ def run_cloud_refactor_requests(
217
414
  outcomes: list[dict] = []
218
415
  counts = {"applied": 0, "denied": 0, "failed": 0, "deferred": 0}
219
416
  notes: list[str] = []
417
+ if testcmd_note:
418
+ notes.append(testcmd_note)
220
419
  stopped = False
221
420
  stop_reason = ""
222
421
  model_id = resolved_model
@@ -0,0 +1,76 @@
1
+ """`refactor account` commands for account context selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from refactorai_cli.auth import AuthError, ensure_authenticated
10
+ from refactorai_cli.client import PlatformClient, PlatformError
11
+ from refactorai_cli.credentials import load_credentials, save_credentials
12
+
13
+ console = Console()
14
+ app = typer.Typer(
15
+ name="account",
16
+ help="List and switch account context for the current developer key.",
17
+ no_args_is_help=True,
18
+ add_completion=False,
19
+ )
20
+
21
+
22
+ def _discover_accounts() -> tuple[list[dict], dict]:
23
+ try:
24
+ auth_ctx = ensure_authenticated(force_remote=True)
25
+ except AuthError as exc:
26
+ console.print(f"[red]Not authenticated:[/red] {exc}")
27
+ raise typer.Exit(code=1) from exc
28
+ client = PlatformClient()
29
+ try:
30
+ rows = client.list_key_accounts(auth_ctx.key.key)
31
+ except PlatformError as exc:
32
+ console.print(f"[red]Could not load accounts:[/red] {exc}")
33
+ raise typer.Exit(code=1) from exc
34
+ return rows, load_credentials()
35
+
36
+
37
+ @app.command("list")
38
+ def account_list() -> None:
39
+ rows, creds = _discover_accounts()
40
+ active_slug = str(creds.get("active_account_slug") or "")
41
+ if not rows:
42
+ console.print("[dim]No accounts available for this key.[/dim]")
43
+ return
44
+ table = Table(title="Accounts")
45
+ table.add_column("current")
46
+ table.add_column("slug")
47
+ table.add_column("name")
48
+ table.add_column("type")
49
+ table.add_column("role")
50
+ for row in rows:
51
+ slug = str(row.get("slug") or "")
52
+ table.add_row("*" if slug and slug == active_slug else "", slug, str(row.get("display_name") or ""), str(row.get("type") or ""), str(row.get("role") or ""))
53
+ console.print(table)
54
+
55
+
56
+ @app.command("use")
57
+ def account_use(
58
+ slug: str = typer.Argument(..., help="Account slug to set active."),
59
+ ) -> None:
60
+ rows, creds = _discover_accounts()
61
+ selected = next((row for row in rows if str(row.get("slug") or "").lower() == slug.lower()), None)
62
+ if selected is None:
63
+ console.print(f"[red]Account not available for this key:[/red] {slug}")
64
+ raise typer.Exit(code=1)
65
+ creds["active_account_id"] = selected.get("id")
66
+ creds["active_account_slug"] = selected.get("slug")
67
+ save_credentials(creds)
68
+ console.print(f"[green]Active account set:[/green] {selected.get('slug')} ({selected.get('display_name')})")
69
+
70
+
71
+ @app.command("current")
72
+ def account_current() -> None:
73
+ creds = load_credentials()
74
+ slug = creds.get("active_account_slug") or "<unset>"
75
+ account_id = creds.get("active_account_id") or "<unset>"
76
+ console.print(f"[bold]active_account_slug[/bold]: {slug}\n[bold]active_account_id[/bold]: {account_id}")
@@ -43,6 +43,8 @@ def login(
43
43
  {
44
44
  "developer_key": developer_key,
45
45
  "account_id": payload.get("account_id"),
46
+ "active_account_id": payload.get("account_id"),
47
+ "active_account_slug": payload.get("account_slug"),
46
48
  "platform_url": platform_url(),
47
49
  "masked_key": mask_key(developer_key),
48
50
  }
@@ -80,6 +82,7 @@ def whoami() -> None:
80
82
  suffix = " (cached)" if ctx.cached else ""
81
83
  console.print(
82
84
  f"[bold]account[/bold]: {ctx.account_id}\n"
85
+ f"[bold]active_account_slug[/bold]: {load_credentials().get('active_account_slug') or 'unknown'}\n"
83
86
  f"[bold]key[/bold]: {mask_key(ctx.key.key)} (source: {source})\n"
84
87
  f"[bold]quota_remaining[/bold]: {quota}{suffix}"
85
88
  )
@@ -53,6 +53,13 @@ from refactor_core.apply import (
53
53
  from refactor_core.budgeting import compute_budget
54
54
  from refactor_core.capabilities import resolve_capabilities
55
55
  from refactor_core.gate import evaluate_gate
56
+ from refactor_core.merge_gate import (
57
+ build_merge_contract,
58
+ merge_contract_result,
59
+ resolve_merge_gate_settings,
60
+ run_l4_adapter,
61
+ should_run_l4,
62
+ )
56
63
  from refactor_core.refactor_requests import (
57
64
  DEFERRED,
58
65
  RequestSuppression,
@@ -68,7 +75,11 @@ from refactor_core.refactor_ops import remediation_hint
68
75
  from refactor_core.indexing import index_target
69
76
  from refactor_core.models import Finding, GeneratedTest, PatchGroup, RunArtifact
70
77
  from refactor_core.rules import annotate_documents_with_rules, filter_documents_by_rules
71
- from refactor_core.security import merge_refactor_findings, suppress_findings_for_rr
78
+ from refactor_core.security import (
79
+ compute_semgrep_security_summary,
80
+ merge_refactor_findings,
81
+ suppress_findings_for_rr,
82
+ )
72
83
  from refactor_core.security.semgrep_runner import EMBEDDED_SEMGREP_SENTINEL
73
84
  from refactor_core.security.suppression import SecuritySuppression
74
85
  from refactor_core.store import (
@@ -635,7 +646,8 @@ def _cloud_inference(
635
646
  model_hint: str | None = None,
636
647
  findings: list | None = None,
637
648
  ) -> RunArtifact:
638
- max_alerts = int(constitution.get_setting("max_alerts_per_run", 5) or 5)
649
+ _raw_alerts = constitution.get_setting("max_alerts_per_run", 5)
650
+ max_alerts = int(_raw_alerts if _raw_alerts is not None else 5)
639
651
  gate_mode = str(constitution.get_setting("gate_mode", "strict") or "strict")
640
652
  excludes = set(constitution.get_setting("exclude", []) or [])
641
653
  documents = index_target(Path(project_root), target, excludes=excludes)
@@ -709,13 +721,40 @@ def _cloud_inference(
709
721
  raise RuntimeError(f"Cloud inference failed ({response.status_code}): {response.text[:500]}")
710
722
 
711
723
  data = response.json() if response.content else {}
712
- findings = [Finding.model_validate(item) for item in (data.get("findings") or [])][:max_alerts]
713
- patches = [PatchGroup.model_validate(item) for item in (data.get("patches") or [])][:max_alerts]
724
+ _no_cap = max_alerts <= 0
725
+ findings = [Finding.model_validate(item) for item in (data.get("findings") or [])]
726
+ patches = [PatchGroup.model_validate(item) for item in (data.get("patches") or [])]
714
727
  generated_tests = [
715
728
  GeneratedTest.model_validate(item) for item in (data.get("generated_tests") or [])
716
- ][:max_alerts]
729
+ ]
730
+ if not _no_cap:
731
+ findings = findings[:max_alerts]
732
+ patches = patches[:max_alerts]
733
+ generated_tests = generated_tests[:max_alerts]
717
734
  gate = evaluate_gate(findings, [], mode=gate_mode)
718
735
 
736
+ # Optional security-scan enrichment (R19). Semgrep is an additive signal and
737
+ # strictly fail-open: it never blocks the core cloud run and the gate stays
738
+ # driven by model findings only. Prefer the server-side baseline (Phase 2)
739
+ # when the platform ran it; otherwise fall back to a client-side scan against
740
+ # the local checkout (Phase 1) so parity holds against older backends.
741
+ security_summary: dict = {}
742
+ security_notes: list[str] = []
743
+ server_summary = data.get("security_summary")
744
+ if isinstance(server_summary, dict) and server_summary.get("enabled"):
745
+ security_summary = server_summary
746
+ else:
747
+ try:
748
+ security_summary, security_notes = compute_semgrep_security_summary(
749
+ project_root=Path(project_root),
750
+ constitution=constitution,
751
+ target=target,
752
+ base_findings=findings,
753
+ )
754
+ except Exception as exc: # optional feature must never fail the cloud run
755
+ security_summary = {}
756
+ security_notes = [f"Security baseline scan skipped (unexpected error): {exc}"]
757
+
719
758
  run_id = uuid.uuid4().hex[:12]
720
759
  if stage == "review":
721
760
  summary = (
@@ -736,9 +775,11 @@ def _cloud_inference(
736
775
  summary=summary,
737
776
  constitution_hash=constitution.content_hash,
738
777
  model_id=str(data.get("model_id") or payload["model_hint"]),
778
+ notes=security_notes,
739
779
  rule_source=rule_meta.get("rule_source", ""),
740
780
  rule_pattern=rule_meta.get("rule_pattern", ""),
741
781
  rule_hash=rule_meta.get("rule_hash", ""),
782
+ security_summary=security_summary,
742
783
  )
743
784
 
744
785
 
@@ -1893,6 +1934,7 @@ def review(
1893
1934
  artifact=artifact,
1894
1935
  )
1895
1936
  _apply_compliance_gate_to_artifact(artifact)
1937
+ _apply_merge_contract_to_artifact(artifact, project_root, project_config)
1896
1938
  run_store.write_artifact(artifact, target=target, status="completed")
1897
1939
  if compliance_frameworks:
1898
1940
  summary = dict(getattr(artifact, "compliance_summary", {}) or {})
@@ -1956,7 +1998,7 @@ def code(
1956
1998
  help="Skip interactive issue approvals in agentic code mode.",
1957
1999
  ),
1958
2000
  on_rr_failure: str = typer.Option(
1959
- "stop",
2001
+ "continue",
1960
2002
  "--on-rr-failure",
1961
2003
  help="RR failure behavior: stop | continue | defer.",
1962
2004
  ),
@@ -2109,6 +2151,7 @@ def code(
2109
2151
  artifact=review_artifact,
2110
2152
  )
2111
2153
  _apply_compliance_gate_to_artifact(review_artifact)
2154
+ _apply_merge_contract_to_artifact(review_artifact, project_root, project_config)
2112
2155
  if compliance_frameworks:
2113
2156
  summary = dict(getattr(review_artifact, "compliance_summary", {}) or {})
2114
2157
  console.print(
@@ -2227,7 +2270,7 @@ def requests(
2227
2270
  help="Auto-approve retry-deferred proposals.",
2228
2271
  ),
2229
2272
  on_rr_failure: str = typer.Option(
2230
- "stop",
2273
+ "continue",
2231
2274
  "--on-rr-failure",
2232
2275
  help="RR failure behavior for retry: stop | continue | defer.",
2233
2276
  ),
@@ -2685,6 +2728,94 @@ def _apply_compliance_gate_to_artifact(artifact: RunArtifact) -> None:
2685
2728
  artifact.gate.blocking_reasons["reasons"] = reasons
2686
2729
 
2687
2730
 
2731
+ def _detect_changed_files(project_root: Path) -> list[str] | None:
2732
+ """Best-effort list of changed files via git (staged + unstaged + untracked).
2733
+
2734
+ Returns ``None`` when git is unavailable or the path is not a repo, so the
2735
+ change-size guardrail degrades to "unknown" rather than a false value.
2736
+ """
2737
+ try:
2738
+ proc = subprocess.run(
2739
+ ["git", "status", "--porcelain"],
2740
+ cwd=str(project_root),
2741
+ capture_output=True,
2742
+ text=True,
2743
+ timeout=15,
2744
+ check=False,
2745
+ )
2746
+ except (OSError, subprocess.SubprocessError):
2747
+ return None
2748
+ if proc.returncode != 0:
2749
+ return None
2750
+ files: list[str] = []
2751
+ for line in (proc.stdout or "").splitlines():
2752
+ name = line[3:].strip() if len(line) > 3 else line.strip()
2753
+ if " -> " in name: # rename: take the destination path
2754
+ name = name.split(" -> ", 1)[1].strip()
2755
+ if name:
2756
+ files.append(name)
2757
+ return files
2758
+
2759
+
2760
+ def _agent_meta_from_env() -> dict:
2761
+ """Resolve agent-authorship metadata from the environment (CI-friendly)."""
2762
+ authored = os.environ.get("REFACTOR_AGENT_AUTHORED", "").strip().lower() in {
2763
+ "1",
2764
+ "true",
2765
+ "yes",
2766
+ "on",
2767
+ }
2768
+ risk_tier = os.environ.get("REFACTOR_AGENT_RISK_TIER", "").strip().lower()
2769
+ return {"authored": authored, "risk_tier": risk_tier}
2770
+
2771
+
2772
+ def _apply_merge_contract_to_artifact(
2773
+ artifact: RunArtifact, project_root: Path, project_config
2774
+ ) -> None:
2775
+ """Assemble the R24 merge contract from existing run outcomes.
2776
+
2777
+ Runs identically for every execution mode (local/cloud_managed/cloud_byok):
2778
+ the CLI always assembles the artifact and persists the report locally, so the
2779
+ contract is computed once here from the gate/compliance/security signals.
2780
+
2781
+ The expensive real-system (L4) layer runs only when it is required (strict
2782
+ profile, agent policy, or an explicit ``REFACTOR_MERGE_RUN_L4`` opt-in) so the
2783
+ fast inner loop stays cheap (R24 Phase 2).
2784
+ """
2785
+ settings = resolve_merge_gate_settings(
2786
+ dict(getattr(project_config, "settings", {}) or {})
2787
+ )
2788
+ agent_meta = _agent_meta_from_env()
2789
+ changed_files = _detect_changed_files(project_root)
2790
+ changed_count = len(changed_files) if isinstance(changed_files, list) else None
2791
+
2792
+ l4_verdict = None
2793
+ if should_run_l4(settings, agent_authored=bool(agent_meta.get("authored"))):
2794
+ console.print("[dim]merge gate: running real-system (L4) validation...[/dim]")
2795
+ try:
2796
+ l4_verdict = run_l4_adapter(
2797
+ settings,
2798
+ project_root=project_root,
2799
+ run_id=str(getattr(artifact, "run_id", "") or ""),
2800
+ constitution_hash=str(getattr(artifact, "constitution_hash", "") or ""),
2801
+ changed_files=changed_files or [],
2802
+ )
2803
+ except Exception as exc: # adapter must never crash the run
2804
+ l4_verdict = {"status": "error", "reason_code": f"l4_adapter_error: {exc}"[:200]}
2805
+
2806
+ contract = build_merge_contract(
2807
+ gate=artifact.gate.model_dump(),
2808
+ compliance_summary=dict(getattr(artifact, "compliance_summary", {}) or {}),
2809
+ security_summary=dict(getattr(artifact, "security_summary", {}) or {}),
2810
+ settings=settings,
2811
+ constitution_hash=str(getattr(artifact, "constitution_hash", "") or ""),
2812
+ agent_meta=agent_meta,
2813
+ l4_verdict=l4_verdict,
2814
+ changed_files_count=changed_count,
2815
+ )
2816
+ artifact.merge_contract = contract
2817
+
2818
+
2688
2819
  def _report_framework_suppressions(
2689
2820
  suppressions: list[RequestSuppression], *, artifact: RunArtifact | None = None
2690
2821
  ) -> None:
@@ -3002,6 +3133,11 @@ def check(
3002
3133
  "--compliance-gate",
3003
3134
  help="Also enforce latest run compliance gate status (for CI/PR pipelines).",
3004
3135
  ),
3136
+ merge_gate: bool = typer.Option(
3137
+ False,
3138
+ "--merge-gate",
3139
+ help="Also enforce the latest run merge contract result (for CI/PR pipelines).",
3140
+ ),
3005
3141
  ) -> None:
3006
3142
  """Check the constitution for committed secrets (developer/provider keys)."""
3007
3143
  files = find_project_files()
@@ -3013,6 +3149,7 @@ def check(
3013
3149
  config = load_config(config_path)
3014
3150
  findings = raw_secret_matches(constitution) + raw_secret_matches_config(config)
3015
3151
  compliance_failed = False
3152
+ merge_failed = False
3016
3153
  if compliance_gate:
3017
3154
  run_dir = head_run_dir(consti_path.parent)
3018
3155
  if run_dir is None:
@@ -3031,6 +3168,29 @@ def check(
3031
3168
  )
3032
3169
  else:
3033
3170
  console.print("[green]Compliance gate[/green]: passed")
3171
+ if merge_gate:
3172
+ run_dir = head_run_dir(consti_path.parent)
3173
+ if run_dir is None:
3174
+ console.print("[yellow]Merge gate check skipped:[/yellow] no runs yet.")
3175
+ else:
3176
+ report = read_report(run_dir) or {}
3177
+ contract = report.get("merge_contract", {}) if isinstance(report, dict) else {}
3178
+ result = merge_contract_result(contract)
3179
+ if result == "blocked":
3180
+ merge_failed = True
3181
+ reasons = contract.get("blocking_reasons") or ["merge_contract_blocked"]
3182
+ console.print(
3183
+ "[red]Merge gate blocked[/red] "
3184
+ f"(profile={contract.get('profile', 'unknown')}): "
3185
+ + ", ".join(str(r) for r in reasons)
3186
+ )
3187
+ elif result == "passed":
3188
+ console.print(
3189
+ f"[green]Merge gate[/green]: passed "
3190
+ f"(profile={contract.get('profile', 'unknown')})"
3191
+ )
3192
+ else:
3193
+ console.print("[yellow]Merge gate[/yellow]: no contract in latest run.")
3034
3194
  if findings:
3035
3195
  kinds = ", ".join(sorted({kind for kind, _ in findings}))
3036
3196
  console.print(
@@ -3038,11 +3198,99 @@ def check(
3038
3198
  f"{consti_path} or {config_path}. Replace secrets with `${{ENV_VAR}}` references."
3039
3199
  )
3040
3200
  raise typer.Exit(code=1 if strict else 0)
3041
- if compliance_failed:
3201
+ if compliance_failed or merge_failed:
3042
3202
  raise typer.Exit(code=1 if strict else 0)
3043
3203
  console.print("[green]OK[/green]: no committed secret-like literals detected.")
3044
3204
 
3045
3205
 
3206
+ def gate() -> None:
3207
+ """Show the resolved merge contract for the latest run (R24, read-only)."""
3208
+ _require_auth()
3209
+ project_root, _constitution, project_config = _load_project()
3210
+ settings = resolve_merge_gate_settings(
3211
+ dict(getattr(project_config, "settings", {}) or {})
3212
+ )
3213
+ console.print("[bold]Merge gate[/bold]")
3214
+ console.print(
3215
+ f" profile: {settings.profile} "
3216
+ f"security_threshold={settings.security_threshold} "
3217
+ f"require_compliance_pass={settings.require_compliance_pass} "
3218
+ f"l4_adapter={settings.real_system_adapter}"
3219
+ )
3220
+
3221
+ run_dir = head_run_dir(project_root)
3222
+ report = read_report(run_dir) if run_dir else {}
3223
+ contract = (
3224
+ dict((report or {}).get("merge_contract", {}) or {})
3225
+ if isinstance(report, dict)
3226
+ else {}
3227
+ )
3228
+ if not contract:
3229
+ console.print(
3230
+ "[yellow]No merge contract in the latest run.[/yellow] "
3231
+ "Run `refactor review` to produce one."
3232
+ )
3233
+ return
3234
+
3235
+ layers = contract.get("layers", {}) if isinstance(contract.get("layers"), dict) else {}
3236
+ table = Table(title="Layers of confidence", show_lines=False)
3237
+ table.add_column("layer", no_wrap=True)
3238
+ table.add_column("enforcement", no_wrap=True)
3239
+ table.add_column("status", no_wrap=True)
3240
+ table.add_column("reason", no_wrap=True)
3241
+ for layer_id in ("well_formed", "internally_correct", "boundary_compatible", "system_correct"):
3242
+ entry = layers.get(layer_id, {}) if isinstance(layers.get(layer_id), dict) else {}
3243
+ code = str(entry.get("code", "")) or layer_id
3244
+ table.add_row(
3245
+ f"{code} {layer_id}",
3246
+ str(entry.get("class", "off")),
3247
+ str(entry.get("status", "unknown")),
3248
+ str(entry.get("reason_code", "") or "-"),
3249
+ )
3250
+ console.print(table)
3251
+
3252
+ # Real-system (L4) verdict details, when the adapter ran.
3253
+ l4 = layers.get("system_correct", {}) if isinstance(layers.get("system_correct"), dict) else {}
3254
+ nf = l4.get("non_functional", {}) if isinstance(l4.get("non_functional"), dict) else {}
3255
+ if l4.get("status") in {"passed", "failed", "error"} or nf.get("within_budget") is not None:
3256
+ parts = [f"status={l4.get('status', 'unknown')}"]
3257
+ if nf.get("p95_latency_ms") is not None:
3258
+ parts.append(f"p95={nf.get('p95_latency_ms')}ms")
3259
+ if nf.get("error_rate_pct") is not None:
3260
+ parts.append(f"error_rate={nf.get('error_rate_pct')}%")
3261
+ if nf.get("within_budget") is not None:
3262
+ parts.append(f"within_budget={nf.get('within_budget')}")
3263
+ console.print(" L4 real-system: " + " ".join(parts))
3264
+ evidence = str(l4.get("evidence_url", "") or "")
3265
+ if evidence:
3266
+ console.print(f" evidence: {evidence}")
3267
+
3268
+ agent = contract.get("agent", {}) if isinstance(contract.get("agent"), dict) else {}
3269
+ if agent.get("authored"):
3270
+ console.print(
3271
+ f" agent: authored=true risk_tier={agent.get('risk_tier', '') or 'unknown'} "
3272
+ f"require_l4={agent.get('require_l4', False)}"
3273
+ )
3274
+ changed = contract.get("changed_files_count")
3275
+ if isinstance(changed, int):
3276
+ console.print(f" changed_files={changed}")
3277
+
3278
+ result = merge_contract_result(contract)
3279
+ result_color = "green" if result == "passed" else ("red" if result == "blocked" else "yellow")
3280
+ console.print(
3281
+ f" quality_gate={contract.get('quality_gate', 'unknown')} "
3282
+ f"compliance={contract.get('compliance', 'unknown')} "
3283
+ f"security_findings={int((contract.get('security', {}) or {}).get('normalized_count', 0) or 0)}"
3284
+ )
3285
+ console.print(f" result: [{result_color}]{result}[/{result_color}]")
3286
+ reasons = contract.get("blocking_reasons") or []
3287
+ if reasons:
3288
+ console.print(" blocking_reasons: " + ", ".join(str(r) for r in reasons))
3289
+ consti_hash = str(contract.get("constitution_hash", "") or "")
3290
+ if consti_hash:
3291
+ console.print(f" [dim]constitution_hash={consti_hash}[/dim]")
3292
+
3293
+
3046
3294
  def _mask_secret(value: str) -> str:
3047
3295
  value = value.strip()
3048
3296
  if len(value) <= 8:
@@ -3484,7 +3732,7 @@ def _report_structural_readiness(project_root: Path, config) -> None:
3484
3732
  if not isinstance(structural, dict):
3485
3733
  structural = {}
3486
3734
  eq_mode = str(structural.get("equivalence_mode", "strict") or "strict")
3487
- rr_policy = str(structural.get("on_rr_failure", "stop") or "stop")
3735
+ rr_policy = str(structural.get("on_rr_failure", "continue") or "continue")
3488
3736
  rewrite_mode = str(structural.get("reference_rewrite", "auto") or "auto")
3489
3737
  langs = structural.get("languages", [])
3490
3738
  rollout = str(structural.get("rollout", "default") or "default")
@@ -219,3 +219,10 @@ def config(ctx: typer.Context) -> None:
219
219
  def compliance(ctx: typer.Context) -> None:
220
220
  """Inspect compliance packs/coverage via proprietary runtime."""
221
221
  _exec_runtime("compliance", list(ctx.args))
222
+
223
+
224
+ def gate(ctx: typer.Context) -> None:
225
+ """Show the latest run's merge contract (read-only; handled locally)."""
226
+ # The merge contract is read from the local run report and needs no
227
+ # proprietary runtime, so it is handled locally for every execution mode.
228
+ _delegate_to_run_cmds("gate", list(ctx.args))
@@ -2,10 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
6
+ import sys
7
+
5
8
  import typer
6
9
 
7
10
  from refactorai_cli import __version__
8
11
  from refactorai_cli.commands import (
12
+ account_cmds,
9
13
  auth_cmds,
10
14
  cloud_cmds,
11
15
  engine_cmds,
@@ -23,6 +27,43 @@ app = typer.Typer(
23
27
  )
24
28
 
25
29
 
30
+ _INTRO_COMMANDS = {"setup", "doctor", "review", "code"}
31
+ _TIP_BY_COMMAND = {
32
+ "setup": "Next: refactor doctor",
33
+ "doctor": "Next: refactor review .",
34
+ "review": "Next: refactor code . --apply",
35
+ "code": "Next: refactor review .",
36
+ }
37
+ _TITLE_BY_COMMAND = {
38
+ "setup": "Refactor Setup Agent",
39
+ "doctor": "Refactor Doctor Agent",
40
+ "review": "Refactor Review Agent",
41
+ "code": "Refactor Code Agent",
42
+ }
43
+
44
+
45
+ def _print_agent_intro(command_name: str) -> None:
46
+ # Keep this lightweight and TTY-only so scripts/non-interactive usage are unchanged.
47
+ if not sys.stdout.isatty():
48
+ return
49
+ title = _TITLE_BY_COMMAND.get(command_name, "Refactor Agent")
50
+ tip = _TIP_BY_COMMAND.get(command_name, "Next: refactor review .")
51
+ green_bold = "\033[1;38;5;46m"
52
+ green_soft = "\033[38;5;120m"
53
+ green_art = "\033[38;5;82m"
54
+ reset = "\033[0m"
55
+ typer.echo("")
56
+ typer.echo(f"{green_bold}{title}{reset}")
57
+ typer.echo(f"{green_soft}Model: Sonnet 4.5{reset}")
58
+ typer.echo(f"Welcome back, {os.environ.get('USER', 'developer')}")
59
+ typer.echo("")
60
+ typer.echo(f"{green_art} * [■ ■] *{reset}")
61
+ typer.echo(f"{green_art} * \\_/ *{reset}")
62
+ typer.echo("")
63
+ typer.echo(f"{green_bold}Tip:{reset} {tip}")
64
+ typer.echo("")
65
+
66
+
26
67
  def _version_callback(value: bool) -> None:
27
68
  if value:
28
69
  typer.echo(f"refactor {__version__}")
@@ -31,6 +72,7 @@ def _version_callback(value: bool) -> None:
31
72
 
32
73
  @app.callback()
33
74
  def main(
75
+ ctx: typer.Context,
34
76
  version: bool = typer.Option(
35
77
  False,
36
78
  "--version",
@@ -41,11 +83,15 @@ def main(
41
83
  ),
42
84
  ) -> None:
43
85
  """refactor CLI."""
86
+ command_name = (ctx.invoked_subcommand or "").strip().lower()
87
+ if command_name in _INTRO_COMMANDS:
88
+ _print_agent_intro(command_name)
44
89
 
45
90
 
46
91
  # Auth (R7.1)
47
92
  app.command()(auth_cmds.login)
48
93
  app.command()(auth_cmds.whoami)
94
+ app.add_typer(account_cmds.app, name="account")
49
95
 
50
96
  # Project / run surface (proxied to activated runtime artifact)
51
97
  _RUNTIME_CTX = {"allow_extra_args": True, "ignore_unknown_options": True}
@@ -65,6 +111,7 @@ app.command(context_settings=_RUNTIME_CTX)(runtime_proxy_cmds.check)
65
111
  app.command(context_settings=_RUNTIME_CTX)(runtime_proxy_cmds.doctor)
66
112
  app.command(context_settings=_RUNTIME_CTX)(runtime_proxy_cmds.config)
67
113
  app.command(context_settings=_RUNTIME_CTX)(runtime_proxy_cmds.compliance)
114
+ app.command(context_settings=_RUNTIME_CTX)(runtime_proxy_cmds.gate)
68
115
 
69
116
  app.command()(setup_cmds.setup)
70
117
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.3.3
3
+ Version: 0.3.5
4
4
  Summary: Local-first CLI for the refactor platform
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -8,7 +8,7 @@ Requires-Dist: typer>=0.12.0
8
8
  Requires-Dist: httpx>=0.27.0
9
9
  Requires-Dist: rich>=13.7.0
10
10
  Requires-Dist: PyYAML>=6.0.1
11
- Requires-Dist: refactorai-core>=3.0.3
11
+ Requires-Dist: refactorai-core>=3.0.4
12
12
 
13
13
  # refactorai-cli
14
14
 
@@ -54,7 +54,7 @@ python -m twine upload ./refactorai-cli/dist/*
54
54
  ## Install test (local)
55
55
 
56
56
  ```bash
57
- python -m pip install ./refactorai-cli/dist/refactorai_cli-0.3.3-py3-none-any.whl
57
+ python -m pip install ./refactorai-cli/dist/refactorai_cli-0.3.4-py3-none-any.whl
58
58
  refactor --version
59
59
  ```
60
60
 
@@ -21,6 +21,7 @@ refactorai_cli.egg-info/entry_points.txt
21
21
  refactorai_cli.egg-info/requires.txt
22
22
  refactorai_cli.egg-info/top_level.txt
23
23
  refactorai_cli/commands/__init__.py
24
+ refactorai_cli/commands/account_cmds.py
24
25
  refactorai_cli/commands/auth_cmds.py
25
26
  refactorai_cli/commands/cloud_cmds.py
26
27
  refactorai_cli/commands/engine_cmds.py
@@ -2,4 +2,4 @@ typer>=0.12.0
2
2
  httpx>=0.27.0
3
3
  rich>=13.7.0
4
4
  PyYAML>=6.0.1
5
- refactorai-core>=3.0.3
5
+ refactorai-core>=3.0.4
File without changes