refactorai-cli 0.3.4__tar.gz → 0.3.8__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.4 → refactorai_cli-0.3.8}/PKG-INFO +2 -2
  2. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/pyproject.toml +2 -2
  3. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/__init__.py +1 -1
  4. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/client.py +18 -0
  5. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/cloud_rr.py +47 -12
  6. refactorai_cli-0.3.8/refactorai_cli/commands/account_cmds.py +76 -0
  7. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/commands/auth_cmds.py +3 -0
  8. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/commands/run_cmds.py +254 -6
  9. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/commands/runtime_proxy_cmds.py +18 -1
  10. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/main.py +54 -0
  11. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli.egg-info/PKG-INFO +2 -2
  12. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli.egg-info/SOURCES.txt +1 -0
  13. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli.egg-info/requires.txt +1 -1
  14. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/README.md +0 -0
  15. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/auth.py +0 -0
  16. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/commands/__init__.py +0 -0
  17. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/commands/cloud_cmds.py +0 -0
  18. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/commands/engine_cmds.py +0 -0
  19. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/commands/model_cmds.py +0 -0
  20. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/commands/rules_cmds.py +0 -0
  21. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/commands/runtime_cmds.py +0 -0
  22. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/commands/setup_cmds.py +0 -0
  23. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/control_plane.py +0 -0
  24. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/credentials.py +0 -0
  25. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/local_constitution.py +0 -0
  26. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/local_engine_runtime.py +0 -0
  27. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/local_paths.py +0 -0
  28. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/model_policy.py +0 -0
  29. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/runtime_manager.py +0 -0
  30. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/settings.py +0 -0
  31. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli/setup_flow.py +0 -0
  32. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli.egg-info/dependency_links.txt +0 -0
  33. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli.egg-info/entry_points.txt +0 -0
  34. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/refactorai_cli.egg-info/top_level.txt +0 -0
  35. {refactorai_cli-0.3.4 → refactorai_cli-0.3.8}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.3.4
3
+ Version: 0.3.8
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.4
11
+ Requires-Dist: refactorai-core>=3.0.5
12
12
 
13
13
  # refactorai-cli
14
14
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "refactorai-cli"
3
- version = "0.3.4"
3
+ version = "0.3.8"
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.4",
15
+ "refactorai-core>=3.0.5",
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.4"
8
+ __version__ = "0.3.8"
@@ -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 []
@@ -51,7 +51,12 @@ from refactor_core.refactor_requests import (
51
51
  save_request,
52
52
  )
53
53
  from refactor_core.store import create_run
54
- from refactor_core.testcmd import _SKIP_DIRS, discover_test_command, resolve_test_command
54
+ from refactor_core.testcmd import (
55
+ _SKIP_DIRS,
56
+ _configured_command,
57
+ discover_test_command,
58
+ resolve_test_command,
59
+ )
55
60
 
56
61
  from refactorai_cli.auth import ensure_authenticated
57
62
  from refactorai_cli.settings import platform_url
@@ -257,18 +262,47 @@ def _resolve_cloud_test_command(
257
262
  """Resolve a test command for a cloud run; return ``(command, note)``.
258
263
 
259
264
  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.
265
+ 1. configured ``verification.tests.command`` (explicit developer intent),
266
+ 2. root-marker detection / deterministic monorepo discovery (subproject
267
+ test setups),
268
+ 3. agentic discovery (server infers from manifests).
269
+
270
+ Cloud runs execute tests on the HOST (no sandbox), so every candidate is
271
+ preflight-validated against the host ``PATH`` before it is committed to. An
272
+ explicitly *configured* command is honored even when preflight fails (the
273
+ developer may know something we don't, e.g. a shell-resolved binary), but the
274
+ mismatch is surfaced as a note. Marker/monorepo-*detected* commands that fail
275
+ preflight are NOT returned: resolution falls through to server discovery so a
276
+ runnable alternative can be suggested instead of locking in an un-runnable
277
+ command (e.g. ``npm test`` when ``npm`` is not installed on the host).
263
278
  """
264
- configured = resolve_test_command(project_root, config)
279
+ # 1) Explicitly configured command: honor developer intent, but warn if the
280
+ # toolchain is not visible on the host PATH.
281
+ configured = _configured_command(config)
265
282
  if configured:
266
- return configured, ""
283
+ if _client_preflight_command(project_root, configured):
284
+ return configured, ""
285
+ return configured, (
286
+ f"configured test command '{configured}' was not found on the host PATH; "
287
+ "cloud runs execute tests on the host, so it may fail. Install the "
288
+ "toolchain or update verification.tests.command."
289
+ )
267
290
 
268
- discovered = discover_test_command(project_root)
269
- if discovered:
270
- return discovered, f"auto-detected test command: {discovered}"
291
+ # 2) Root-marker / monorepo detection: only usable if runnable on the host.
292
+ detected = resolve_test_command(project_root, config) or discover_test_command(project_root)
293
+ if detected:
294
+ if _client_preflight_command(project_root, detected):
295
+ return detected, f"auto-detected test command: {detected}"
296
+ # Detected but not runnable on the host -> fall through to server
297
+ # discovery rather than committing to an un-runnable command.
298
+ detected_note = (
299
+ f"auto-detected test command '{detected}' but its toolchain was not "
300
+ "found on the host PATH; trying server discovery."
301
+ )
302
+ else:
303
+ detected_note = ""
271
304
 
305
+ # 3) Server-assisted discovery, preflight-validated.
272
306
  suggested = _discover_test_command_via_cloud(
273
307
  project_root=project_root,
274
308
  constitution=constitution,
@@ -280,11 +314,12 @@ def _resolve_cloud_test_command(
280
314
  if suggested and _client_preflight_command(project_root, suggested):
281
315
  return suggested, f"server-suggested test command: {suggested}"
282
316
  if suggested:
283
- return None, (
317
+ note = (
284
318
  f"server suggested test command '{suggested}' but its toolchain was not "
285
- "found on PATH; skipped."
319
+ "found on the host PATH; skipped."
286
320
  )
287
- return None, ""
321
+ return None, f"{detected_note} {note}".strip() if detected_note else note
322
+ return None, detected_note
288
323
 
289
324
 
290
325
  class _Session:
@@ -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 {})
@@ -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(
@@ -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:
@@ -75,7 +75,17 @@ def _delegate_to_run_cmds(command_name: str, passthrough_args: list[str]) -> Non
75
75
  real command signature (options, arguments, help) is honored without manual
76
76
  argument parsing. Exit codes propagate to the caller.
77
77
  """
78
- from refactorai_cli.commands import run_cmds
78
+ try:
79
+ from refactorai_cli.commands import run_cmds
80
+ except ModuleNotFoundError as exc:
81
+ if exc.name == "refactor_core.merge_gate":
82
+ console.print(
83
+ "[red]Installed refactor core is too old for this CLI.[/red]\n"
84
+ "Please upgrade both packages:\n"
85
+ " pip install --upgrade refactorai-core refactorai-cli"
86
+ )
87
+ raise typer.Exit(code=1) from exc
88
+ raise
79
89
 
80
90
  func = getattr(run_cmds, command_name, None)
81
91
  if func is None: # pragma: no cover - defensive; all cloud verbs exist
@@ -219,3 +229,10 @@ def config(ctx: typer.Context) -> None:
219
229
  def compliance(ctx: typer.Context) -> None:
220
230
  """Inspect compliance packs/coverage via proprietary runtime."""
221
231
  _exec_runtime("compliance", list(ctx.args))
232
+
233
+
234
+ def gate(ctx: typer.Context) -> None:
235
+ """Show the latest run's merge contract (read-only; handled locally)."""
236
+ # The merge contract is read from the local run report and needs no
237
+ # proprietary runtime, so it is handled locally for every execution mode.
238
+ _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,50 @@ 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
+ _COLOR_BY_COMMAND = {
44
+ "setup": ("245", "250", "247"), # base, soft, art
45
+ "doctor": ("39", "117", "81"),
46
+ "review": ("214", "222", "220"),
47
+ "code": ("46", "120", "82"),
48
+ }
49
+
50
+
51
+ def _print_agent_intro(command_name: str) -> None:
52
+ # Keep this lightweight and TTY-only so scripts/non-interactive usage are unchanged.
53
+ if not sys.stdout.isatty():
54
+ return
55
+ title = _TITLE_BY_COMMAND.get(command_name, "Refactor Agent")
56
+ tip = _TIP_BY_COMMAND.get(command_name, "Next: refactor review .")
57
+ base, soft, art = _COLOR_BY_COMMAND.get(command_name, ("46", "120", "82"))
58
+ color_bold = f"\033[1;38;5;{base}m"
59
+ color_soft = f"\033[38;5;{soft}m"
60
+ color_art = f"\033[38;5;{art}m"
61
+ reset = "\033[0m"
62
+ typer.echo("")
63
+ typer.echo(f"{color_bold}{title}{reset}")
64
+ typer.echo(f"{color_soft}Model: Sonnet 4.5{reset}")
65
+ typer.echo(f"Welcome back, {os.environ.get('USER', 'developer')}")
66
+ typer.echo("")
67
+ typer.echo(f"{color_art} * [■ ■] *{reset}")
68
+ typer.echo(f"{color_art} * \\_/ *{reset}")
69
+ typer.echo("")
70
+ typer.echo(f"{color_bold}Tip:{reset} {tip}")
71
+ typer.echo("")
72
+
73
+
26
74
  def _version_callback(value: bool) -> None:
27
75
  if value:
28
76
  typer.echo(f"refactor {__version__}")
@@ -31,6 +79,7 @@ def _version_callback(value: bool) -> None:
31
79
 
32
80
  @app.callback()
33
81
  def main(
82
+ ctx: typer.Context,
34
83
  version: bool = typer.Option(
35
84
  False,
36
85
  "--version",
@@ -41,11 +90,15 @@ def main(
41
90
  ),
42
91
  ) -> None:
43
92
  """refactor CLI."""
93
+ command_name = (ctx.invoked_subcommand or "").strip().lower()
94
+ if command_name in _INTRO_COMMANDS:
95
+ _print_agent_intro(command_name)
44
96
 
45
97
 
46
98
  # Auth (R7.1)
47
99
  app.command()(auth_cmds.login)
48
100
  app.command()(auth_cmds.whoami)
101
+ app.add_typer(account_cmds.app, name="account")
49
102
 
50
103
  # Project / run surface (proxied to activated runtime artifact)
51
104
  _RUNTIME_CTX = {"allow_extra_args": True, "ignore_unknown_options": True}
@@ -65,6 +118,7 @@ app.command(context_settings=_RUNTIME_CTX)(runtime_proxy_cmds.check)
65
118
  app.command(context_settings=_RUNTIME_CTX)(runtime_proxy_cmds.doctor)
66
119
  app.command(context_settings=_RUNTIME_CTX)(runtime_proxy_cmds.config)
67
120
  app.command(context_settings=_RUNTIME_CTX)(runtime_proxy_cmds.compliance)
121
+ app.command(context_settings=_RUNTIME_CTX)(runtime_proxy_cmds.gate)
68
122
 
69
123
  app.command()(setup_cmds.setup)
70
124
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.3.4
3
+ Version: 0.3.8
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.4
11
+ Requires-Dist: refactorai-core>=3.0.5
12
12
 
13
13
  # refactorai-cli
14
14
 
@@ -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.4
5
+ refactorai-core>=3.0.5
File without changes
File without changes