refactorai-cli 0.4.16__tar.gz → 0.5.0__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 (50) hide show
  1. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/PKG-INFO +2 -2
  2. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/pyproject.toml +2 -2
  3. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/__init__.py +1 -1
  4. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/auth.py +4 -0
  5. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/client.py +55 -0
  6. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/hook_cmds.py +89 -4
  7. refactorai_cli-0.5.0/refactorai_cli/commands/pre_push_cmds.py +151 -0
  8. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/run_cmds.py +153 -14
  9. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/watch_cmds.py +34 -0
  10. refactorai_cli-0.5.0/refactorai_cli/commit_telemetry.py +196 -0
  11. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/main.py +6 -0
  12. refactorai_cli-0.5.0/refactorai_cli/pre_push_gate.py +112 -0
  13. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli.egg-info/PKG-INFO +2 -2
  14. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli.egg-info/SOURCES.txt +3 -0
  15. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli.egg-info/requires.txt +1 -1
  16. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/README.md +0 -0
  17. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/cloud_rr.py +0 -0
  18. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/__init__.py +0 -0
  19. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/account_cmds.py +0 -0
  20. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/auth_cmds.py +0 -0
  21. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/branch_cmds.py +0 -0
  22. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/cloud_cmds.py +0 -0
  23. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/engine_cmds.py +0 -0
  24. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/model_cmds.py +0 -0
  25. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/request_cmds.py +0 -0
  26. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/rules_cmds.py +0 -0
  27. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/runtime_cmds.py +0 -0
  28. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
  29. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/setup_cmds.py +0 -0
  30. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/toolchains_cmds.py +0 -0
  31. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commit_queue.py +0 -0
  32. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/control_plane.py +0 -0
  33. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/credentials.py +0 -0
  34. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/dotenv_loader.py +0 -0
  35. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/git_scope.py +0 -0
  36. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/local_constitution.py +0 -0
  37. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/local_engine_runtime.py +0 -0
  38. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/local_paths.py +0 -0
  39. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/model_policy.py +0 -0
  40. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/refactor_branch_store.py +0 -0
  41. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/review_runner.py +0 -0
  42. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/runtime_manager.py +0 -0
  43. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/settings.py +0 -0
  44. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/setup_flow.py +0 -0
  45. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/watch_state.py +0 -0
  46. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/watch_supervisor.py +0 -0
  47. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli.egg-info/dependency_links.txt +0 -0
  48. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli.egg-info/entry_points.txt +0 -0
  49. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli.egg-info/top_level.txt +0 -0
  50. {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.4.16
3
+ Version: 0.5.0
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.2.9
11
+ Requires-Dist: refactorai-core>=3.2.11
12
12
 
13
13
  # refactorai-cli
14
14
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "refactorai-cli"
3
- version = "0.4.16"
3
+ version = "0.5.0"
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.2.9",
15
+ "refactorai-core>=3.2.11",
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.4.16"
8
+ __version__ = "0.5.0"
@@ -27,6 +27,7 @@ class AuthContext:
27
27
  account_id: str
28
28
  quota_remaining: int | None
29
29
  cached: bool
30
+ account_slug: str = ""
30
31
 
31
32
 
32
33
  def _fingerprint(key: str) -> str:
@@ -56,6 +57,7 @@ def _write_cache(key: str, payload: dict) -> None:
56
57
  expires_epoch = _parse_expiry(payload.get("expires_at"))
57
58
  record = {
58
59
  "account_id": payload.get("account_id"),
60
+ "account_slug": payload.get("account_slug"),
59
61
  "quota_remaining": payload.get("quota_remaining"),
60
62
  "validation_token": payload.get("validation_token"),
61
63
  "expires_at": payload.get("expires_at"),
@@ -100,6 +102,7 @@ def ensure_authenticated(
100
102
  account_id=cached.get("account_id", "unknown"),
101
103
  quota_remaining=cached.get("quota_remaining"),
102
104
  cached=True,
105
+ account_slug=cached.get("account_slug") or "",
103
106
  )
104
107
 
105
108
  client = client or PlatformClient()
@@ -117,4 +120,5 @@ def ensure_authenticated(
117
120
  account_id=payload.get("account_id", "unknown"),
118
121
  quota_remaining=payload.get("quota_remaining"),
119
122
  cached=False,
123
+ account_slug=payload.get("account_slug") or "",
120
124
  )
@@ -45,6 +45,61 @@ class PlatformClient:
45
45
  )
46
46
  return response.json()
47
47
 
48
+ def create_project(
49
+ self,
50
+ developer_key: str,
51
+ account_slug: str,
52
+ name: str,
53
+ *,
54
+ idempotency_key: str,
55
+ provider: str = "github",
56
+ default_branch: str = "main",
57
+ visibility: str = "private",
58
+ repo_url: str | None = None,
59
+ ) -> dict:
60
+ """Create a project under ``account_slug`` and return the created record.
61
+
62
+ Used by ``refactor init`` to auto-provision a cloud project for the local
63
+ commit loop. ``repo_url`` is optional; it can be attached later from the
64
+ web UI. Raises ``PlatformError`` on any rejection or transport failure.
65
+ """
66
+ body: dict = {
67
+ "name": name,
68
+ "provider": provider,
69
+ "default_branch": default_branch,
70
+ "visibility": visibility,
71
+ }
72
+ if repo_url:
73
+ body["repo_url"] = repo_url
74
+ try:
75
+ response = httpx.post(
76
+ f"{self.base_url}/v1/accounts/{account_slug}/projects",
77
+ headers={
78
+ "Authorization": f"Bearer {developer_key}",
79
+ "Content-Type": "application/json",
80
+ "Idempotency-Key": idempotency_key,
81
+ },
82
+ json=body,
83
+ timeout=self.timeout,
84
+ )
85
+ except httpx.HTTPError as exc:
86
+ raise PlatformError(f"Could not reach platform at {self.base_url}: {exc}") from exc
87
+
88
+ if response.status_code == 401:
89
+ raise PlatformError("Developer key is invalid or revoked", status_code=401)
90
+ if response.status_code >= 400:
91
+ detail = ""
92
+ try:
93
+ detail = str((response.json() or {}).get("detail", ""))
94
+ except Exception:
95
+ detail = response.text[:200]
96
+ suffix = f": {detail}" if detail else ""
97
+ raise PlatformError(
98
+ f"Project creation failed ({response.status_code}){suffix}",
99
+ status_code=response.status_code,
100
+ )
101
+ return response.json() if response.content else {}
102
+
48
103
  def list_key_accounts(self, developer_key: str) -> list[dict]:
49
104
  try:
50
105
  response = httpx.get(
@@ -27,6 +27,7 @@ app = typer.Typer(
27
27
  )
28
28
 
29
29
  _HOOK_MARKER = "RefactorAI post-commit hook"
30
+ _PRE_PUSH_MARKER = "RefactorAI pre-push hook"
30
31
  _DEFAULT_HOOKS_DIR = ".githooks"
31
32
 
32
33
  # Non-blocking + fail-open: capture HEAD, then detach the enqueue so `git commit`
@@ -46,6 +47,18 @@ fi
46
47
  exit 0
47
48
  """
48
49
 
50
+ # Blocking-capable pre-push gate. `refactor pre-push` is itself fail-open and only
51
+ # ever exits non-zero under a `strict` policy, so the default (warn) never blocks.
52
+ _PRE_PUSH_SCRIPT = """#!/usr/bin/env bash
53
+ # RefactorAI pre-push hook (docs/44 Phase 2 gate).
54
+ # Delegates to `refactor pre-push`, which enforces the project's pre-push policy.
55
+ if ! command -v refactor >/dev/null 2>&1; then
56
+ exit 0
57
+ fi
58
+ refactor pre-push
59
+ exit $?
60
+ """
61
+
49
62
 
50
63
  def _require_repo(project_root: Path) -> str:
51
64
  root = git_scope.repo_root(project_root)
@@ -98,14 +111,39 @@ def perform_install(
98
111
  "message": "could not set core.hooksPath",
99
112
  }
100
113
 
114
+ pre_push_status = _install_pre_push(hooks_path, force=force)
115
+
101
116
  return {
102
117
  "status": "installed",
103
118
  "rel_dir": rel_dir,
104
119
  "hook_file": hook_file,
120
+ "pre_push_status": pre_push_status,
121
+ "pre_push_file": hooks_path / "pre-push",
105
122
  "message": f"installed post-commit hook -> {hook_file}",
106
123
  }
107
124
 
108
125
 
126
+ def _install_pre_push(hooks_path: Path, *, force: bool) -> str:
127
+ """Write the pre-push gate hook. Additive + best-effort (never fails install).
128
+
129
+ Returns ``installed`` (written/refreshed), ``exists_foreign`` (a non-RefactorAI
130
+ pre-push hook is present and ``force`` was not set) or ``error``.
131
+ """
132
+ pre_push_file = hooks_path / "pre-push"
133
+ if (
134
+ pre_push_file.exists()
135
+ and _PRE_PUSH_MARKER not in _safe_read(pre_push_file)
136
+ and not force
137
+ ):
138
+ return "exists_foreign"
139
+ try:
140
+ pre_push_file.write_text(_PRE_PUSH_SCRIPT, encoding="utf-8")
141
+ pre_push_file.chmod(pre_push_file.stat().st_mode | 0o755)
142
+ except OSError:
143
+ return "error"
144
+ return "installed"
145
+
146
+
109
147
  @app.command("install")
110
148
  def install(
111
149
  hooks_dir: str | None = typer.Option(
@@ -133,11 +171,24 @@ def install(
133
171
  console.print(f"[red]hook: could not install:[/red] {result['message']}")
134
172
  raise typer.Exit(code=1)
135
173
 
174
+ pre_push_status = result.get("pre_push_status")
175
+ if pre_push_status == "installed":
176
+ pre_push_line = f"[green]Installed[/green] pre-push gate hook -> {result.get('pre_push_file')}\n"
177
+ elif pre_push_status == "exists_foreign":
178
+ pre_push_line = (
179
+ "[yellow]Skipped pre-push hook:[/yellow] a non-RefactorAI pre-push hook already exists "
180
+ "(re-run with --force to overwrite).\n"
181
+ )
182
+ else:
183
+ pre_push_line = "[yellow]Could not install the pre-push gate hook.[/yellow]\n"
184
+
136
185
  console.print(
137
186
  f"[green]Installed[/green] post-commit hook -> {result['hook_file']}\n"
187
+ f"{pre_push_line}"
138
188
  f"[dim]core.hooksPath = {result['rel_dir']}[/dim]\n"
139
189
  "Every commit now enqueues a local review event. "
140
190
  "Open the Refactor Terminal with `refactor watch`.\n"
191
+ "[dim]The pre-push gate is advisory (warn) until a platform admin sets it to strict.[/dim]\n"
141
192
  "[dim]Tip: set watch.auto_open + watch.terminal (or REFACTOR_WATCH_AUTO_OPEN / "
142
193
  "REFACTOR_WATCH_TERMINAL) to open it automatically on each commit.[/dim]"
143
194
  )
@@ -155,23 +206,32 @@ def uninstall(
155
206
  root = Path(_require_repo(Path.cwd()))
156
207
  rel_dir = git_scope.get_config(root, "core.hooksPath") or _DEFAULT_HOOKS_DIR
157
208
  hook_file = (root / rel_dir / "post-commit").resolve()
209
+ pre_push_file = (root / rel_dir / "pre-push").resolve()
158
210
 
159
- removed = False
211
+ removed = []
160
212
  if hook_file.exists() and _HOOK_MARKER in _safe_read(hook_file):
161
213
  try:
162
214
  hook_file.unlink()
163
- removed = True
215
+ removed.append("post-commit")
164
216
  except OSError as exc:
165
217
  console.print(f"[red]hook: could not remove hook:[/red] {exc}")
166
218
  raise typer.Exit(code=1) from exc
167
219
 
220
+ if pre_push_file.exists() and _PRE_PUSH_MARKER in _safe_read(pre_push_file):
221
+ try:
222
+ pre_push_file.unlink()
223
+ removed.append("pre-push")
224
+ except OSError as exc:
225
+ console.print(f"[red]hook: could not remove pre-push hook:[/red] {exc}")
226
+ raise typer.Exit(code=1) from exc
227
+
168
228
  if reset_hooks_path:
169
229
  git_scope.unset_config(root, "core.hooksPath")
170
230
 
171
231
  if removed:
172
- console.print("[green]Removed[/green] the RefactorAI post-commit hook.")
232
+ console.print(f"[green]Removed[/green] the RefactorAI {' and '.join(removed)} hook(s).")
173
233
  else:
174
- console.print("[dim]hook: no RefactorAI post-commit hook found to remove.[/dim]")
234
+ console.print("[dim]hook: no RefactorAI hooks found to remove.[/dim]")
175
235
 
176
236
 
177
237
  @app.command("enqueue")
@@ -201,6 +261,23 @@ def enqueue(
201
261
  # and fail-open so it never affects the commit flow (docs/42 `R29`, Phase 4).
202
262
  if enqueued:
203
263
  _maybe_auto_open(project_root, verbose)
264
+ _maybe_report_commit(project_root, event, verbose)
265
+
266
+
267
+ def _maybe_report_commit(project_root: Path, event: dict | None, verbose: bool) -> None:
268
+ """Best-effort opt-in commit telemetry (docs/44 Phase 3). Never raises."""
269
+ if not event:
270
+ return
271
+ try:
272
+ from refactorai_cli import commit_telemetry
273
+
274
+ body = commit_telemetry.build_commit_event_from_queue(event)
275
+ posted = commit_telemetry.post_commit_event(project_root, body)
276
+ if verbose and posted:
277
+ console.print("[dim]hook: reported commit telemetry.[/dim]")
278
+ except Exception as exc: # fail-open
279
+ if verbose:
280
+ console.print(f"[yellow]hook: telemetry skipped ({exc}).[/yellow]")
204
281
 
205
282
 
206
283
  def _watch_settings(project_root: Path) -> dict:
@@ -245,7 +322,9 @@ def status() -> None:
245
322
 
246
323
  rel_dir = git_scope.get_config(root, "core.hooksPath")
247
324
  hook_file = (root / (rel_dir or _DEFAULT_HOOKS_DIR) / "post-commit").resolve()
325
+ pre_push_file = (root / (rel_dir or _DEFAULT_HOOKS_DIR) / "pre-push").resolve()
248
326
  is_ours = hook_file.exists() and _HOOK_MARKER in _safe_read(hook_file)
327
+ pre_push_ours = pre_push_file.exists() and _PRE_PUSH_MARKER in _safe_read(pre_push_file)
249
328
  q_path = commit_queue.queue_path(root)
250
329
 
251
330
  console.print(f"Repository: [bold]{root}[/bold]")
@@ -256,6 +335,12 @@ def status() -> None:
256
335
  console.print(f"post-commit hook: [yellow]present but not RefactorAI[/yellow] ({hook_file})")
257
336
  else:
258
337
  console.print("post-commit hook: [red]not installed[/red] (run `refactor hook install`)")
338
+ if pre_push_ours:
339
+ console.print(f"pre-push hook: [green]installed[/green] ({pre_push_file})")
340
+ elif pre_push_file.exists():
341
+ console.print(f"pre-push hook: [yellow]present but not RefactorAI[/yellow] ({pre_push_file})")
342
+ else:
343
+ console.print("pre-push hook: [red]not installed[/red] (run `refactor hook install`)")
259
344
  console.print(f"Queue: {q_path}")
260
345
  console.print(f"Pending events: [bold]{commit_queue.pending_count(root)}[/bold]")
261
346
 
@@ -0,0 +1,151 @@
1
+ """`refactor pre-push`: the local pre-push quality gate (docs/44 Phase 2).
2
+
3
+ Invoked by the installed git ``pre-push`` hook. It reads the project's pre-push
4
+ policy from ``refactor.config`` (falling back to safe defaults), inspects the
5
+ local Refactor Request store, and decides whether to allow the push.
6
+
7
+ Contract:
8
+ - Fail-open: any unexpected error allows the push (exit 0). The gate must never
9
+ wedge a developer because of a local error.
10
+ - ``off``/``warn`` never block. Only ``strict`` can exit non-zero.
11
+ - Escape hatch: ``REFACTOR_SKIP=1`` bypasses a strict block when policy allows;
12
+ ``REFACTOR_SKIP_REASON`` supplies the reason (recorded when auditing is on).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+
22
+ import typer
23
+ from rich.console import Console
24
+
25
+ from refactorai_cli import git_scope, pre_push_gate
26
+
27
+ console = Console()
28
+
29
+ _TRUTHY = {"1", "true", "yes", "on"}
30
+ _HIGH_SEVERITIES = {"high", "critical"}
31
+
32
+
33
+ def _project_root() -> Path:
34
+ root = git_scope.repo_root(Path.cwd())
35
+ return Path(root) if root else Path.cwd()
36
+
37
+
38
+ def _load_policy(project_root: Path) -> dict:
39
+ """Best-effort read of the ``pre_push`` policy from refactor.config settings."""
40
+ policy = dict(pre_push_gate.DEFAULT_POLICY)
41
+ policy["block_on"] = list(pre_push_gate.DEFAULT_POLICY["block_on"])
42
+ try:
43
+ from refactor_core.constitution import CONFIG_FILENAME, find_project_files, load_config
44
+
45
+ consti_path, _ = find_project_files(project_root)
46
+ if consti_path is None:
47
+ return policy
48
+ cfg = load_config(consti_path.parent / CONFIG_FILENAME)
49
+ settings = dict(getattr(cfg, "settings", {}) or {})
50
+ stored = settings.get("pre_push")
51
+ if isinstance(stored, dict):
52
+ policy.update(stored)
53
+ if not isinstance(policy.get("block_on"), (list, tuple)):
54
+ policy["block_on"] = list(pre_push_gate.DEFAULT_POLICY["block_on"])
55
+ except Exception:
56
+ # Fail-open on any config error: keep defaults (warn).
57
+ pass
58
+ return policy
59
+
60
+
61
+ def _count_requests(project_root: Path) -> tuple[int, int]:
62
+ """Return ``(open_requests, high_severity_open)`` from the local RR store."""
63
+ try:
64
+ from refactor_core.refactor_requests import pending_requests
65
+
66
+ pending = pending_requests(project_root)
67
+ except Exception:
68
+ return 0, 0
69
+ high = sum(1 for r in pending if str(getattr(r, "severity", "")).lower() in _HIGH_SEVERITIES)
70
+ return len(pending), high
71
+
72
+
73
+ def _record_skip(project_root: Path, *, reasons: list[str], reason: str) -> None:
74
+ try:
75
+ from refactor_core.paths import ensure_dir, project_store_dir
76
+
77
+ audit_path = ensure_dir(project_store_dir(project_root)) / "pre_push_audit.jsonl"
78
+ entry = {
79
+ "at": datetime.now(timezone.utc).isoformat(),
80
+ "event": "pre_push_bypass",
81
+ "reason": reason,
82
+ "blockers": reasons,
83
+ "user": os.environ.get("USER") or os.environ.get("USERNAME") or "",
84
+ }
85
+ with audit_path.open("a", encoding="utf-8") as handle:
86
+ handle.write(json.dumps(entry) + "\n")
87
+ except Exception:
88
+ # Auditing is best-effort and must never affect the push decision.
89
+ pass
90
+
91
+
92
+ def pre_push(
93
+ ctx: typer.Context,
94
+ json_out: bool = typer.Option(False, "--json", help="Emit the gate decision as JSON."),
95
+ ) -> None:
96
+ """Evaluate the pre-push gate for the current repository."""
97
+ try:
98
+ project_root = _project_root()
99
+ policy = _load_policy(project_root)
100
+ open_requests, high_severity_open = _count_requests(project_root)
101
+ skip_requested = str(os.environ.get("REFACTOR_SKIP", "")).strip().lower() in _TRUTHY
102
+ skip_reason = os.environ.get("REFACTOR_SKIP_REASON", "")
103
+
104
+ decision = pre_push_gate.evaluate(
105
+ policy,
106
+ open_requests=open_requests,
107
+ high_severity_open=high_severity_open,
108
+ skip_requested=skip_requested,
109
+ skip_reason=skip_reason,
110
+ )
111
+
112
+ if decision.skipped and policy.get("audit_skips", True):
113
+ _record_skip(project_root, reasons=decision.reasons, reason=decision.skip_reason)
114
+
115
+ if json_out:
116
+ console.print_json(
117
+ data={
118
+ "allow": decision.allow,
119
+ "mode": decision.mode,
120
+ "reasons": decision.reasons,
121
+ "skipped": decision.skipped,
122
+ }
123
+ )
124
+ else:
125
+ _render(decision)
126
+
127
+ if not decision.allow:
128
+ raise typer.Exit(code=1)
129
+ except typer.Exit:
130
+ raise
131
+ except Exception as exc: # fail-open: never wedge a push on a local error
132
+ console.print(f"[yellow]pre-push: gate skipped (fail-open): {exc}[/yellow]")
133
+
134
+
135
+ def _render(decision: pre_push_gate.GateDecision) -> None:
136
+ if decision.mode == "off":
137
+ return
138
+ if not decision.reasons:
139
+ console.print("[green]pre-push:[/green] gate passed — nothing blocking this push.")
140
+ return
141
+ heading = "[red]pre-push: push blocked[/red]" if not decision.allow else "[yellow]pre-push: warnings[/yellow]"
142
+ console.print(heading)
143
+ for reason in decision.reasons:
144
+ console.print(f" • {reason}")
145
+ if decision.skipped:
146
+ console.print("[yellow] bypassed via REFACTOR_SKIP[/yellow]")
147
+ elif not decision.allow:
148
+ console.print(
149
+ "[dim] Resolve with `refactor watch` / `refactor code`, or bypass with "
150
+ "REFACTOR_SKIP=1 git push.[/dim]"
151
+ )
@@ -102,11 +102,13 @@ from refactor_core.store import (
102
102
  )
103
103
  from refactor_core.testcmd import resolve_test_command
104
104
 
105
- from refactorai_cli.auth import AuthError, ensure_authenticated
105
+ from refactorai_cli.auth import AuthContext, AuthError, ensure_authenticated
106
+ from refactorai_cli.client import PlatformClient, PlatformError
107
+ from refactorai_cli.credentials import load_credentials, save_credentials
106
108
  from refactorai_cli.dotenv_loader import DotenvResult, load_project_dotenv
107
109
  from refactorai_cli.local_engine_runtime import DEFAULT_ENGINE_PORT, read_engine_state
108
110
  from refactorai_cli.runtime_manager import runtime_status, runtime_version_dir
109
- from refactorai_cli.settings import platform_url
111
+ from refactorai_cli.settings import mask_key, platform_url
110
112
  from refactorai_cli.setup_flow import (
111
113
  BACKEND_BYOK,
112
114
  BACKEND_LOCAL_SERVER,
@@ -1033,7 +1035,14 @@ def init(
1033
1035
  help=f"Overwrite existing {CONSTITUTION_FILENAME} and {CONFIG_FILENAME}.",
1034
1036
  ),
1035
1037
  ) -> None:
1036
- """Create `refactor.consti` + `refactor.config` and register the project."""
1038
+ """Create `refactor.consti` + `refactor.config` and register the project.
1039
+
1040
+ `init` is online-first: it authenticates (prompting for a developer key when
1041
+ needed) and auto-creates a cloud project so the local commit loop reports to
1042
+ the platform out of the box. The folder name becomes the project name (edit
1043
+ it later in the web UI) and the generated project id is written to
1044
+ `refactor.config`. No provider or repository details are required here.
1045
+ """
1037
1046
  try:
1038
1047
  project_root = Path.cwd()
1039
1048
  except OSError:
@@ -1052,14 +1061,48 @@ def init(
1052
1061
  "Use --force to overwrite."
1053
1062
  )
1054
1063
  raise typer.Exit(code=1)
1064
+
1065
+ # Authenticate and provision the cloud project BEFORE writing any files, so a
1066
+ # failure leaves the working tree untouched (fail-fast, no partial state).
1067
+ auth_ctx = _ensure_init_auth(project_root)
1068
+ account_slug = auth_ctx.account_slug or load_credentials().get("active_account_slug") or ""
1069
+ if not account_slug:
1070
+ console.print(
1071
+ "[red]Could not resolve your account.[/red] Run `refactor login` and retry."
1072
+ )
1073
+ raise typer.Exit(code=1)
1074
+
1075
+ project_name = project_root.name or "refactor-project"
1076
+ client = PlatformClient()
1077
+ try:
1078
+ created = client.create_project(
1079
+ auth_ctx.key.key,
1080
+ account_slug,
1081
+ project_name,
1082
+ idempotency_key=str(uuid.uuid4()),
1083
+ )
1084
+ except PlatformError as exc:
1085
+ console.print(f"[red]Could not create cloud project:[/red] {exc}")
1086
+ raise typer.Exit(code=1) from exc
1087
+ project_id = str(created.get("id") or "").strip()
1088
+ if not project_id:
1089
+ console.print("[red]Cloud project creation returned no id.[/red] Please retry.")
1090
+ raise typer.Exit(code=1)
1091
+
1055
1092
  consti_target.write_text(DEFAULT_CONSTITUTION, encoding="utf-8")
1056
1093
  config_text, config_profile = _default_config_for_init()
1094
+ config_text = _inject_project_id(config_text, project_id)
1057
1095
  config_target.write_text(config_text, encoding="utf-8")
1058
1096
 
1059
1097
  constitution = load_constitution(consti_target)
1060
1098
  record = register_project(project_root, constitution_hash=constitution.content_hash)
1061
1099
  console.print(f"[green]Created[/green] {consti_target}")
1062
1100
  console.print(f"[green]Created[/green] {config_target}")
1101
+ console.print(
1102
+ f"[green]Linked[/green] to cloud project [bold]{project_name}[/bold] "
1103
+ f"(account {account_slug}). The project id is saved in {CONFIG_FILENAME}; "
1104
+ "rename the project or attach a repository anytime from the web UI."
1105
+ )
1063
1106
  if config_profile == "local_server":
1064
1107
  console.print(
1065
1108
  "[green]Configured[/green] refactor.config for local server setup "
@@ -1081,6 +1124,79 @@ def init(
1081
1124
  _offer_commit_hook(project_root)
1082
1125
 
1083
1126
 
1127
+ def _ensure_init_auth(project_root: Path) -> AuthContext:
1128
+ """Return a validated ``AuthContext`` for `init`, prompting to log in if needed.
1129
+
1130
+ Tries the resolved developer key first (env/config/credentials). When none is
1131
+ configured or it is rejected, an interactive session prompts for a developer
1132
+ key and stores it in the central credentials file. Non-interactive runs fail
1133
+ fast with guidance rather than hanging on a prompt.
1134
+ """
1135
+ try:
1136
+ return ensure_authenticated(project_root, force_remote=True)
1137
+ except AuthError:
1138
+ pass
1139
+
1140
+ if not sys.stdin.isatty():
1141
+ console.print(
1142
+ "[red]`refactor init` requires authentication.[/red] "
1143
+ "Set REFACTOR_API_KEY or run `refactor login` first."
1144
+ )
1145
+ raise typer.Exit(code=1)
1146
+
1147
+ console.print("[bold]Sign in to Refactor[/bold] to create your cloud project.")
1148
+ console.print(
1149
+ f"[dim]No account yet? Create one and generate a developer key at "
1150
+ f"{platform_url()}, then paste it below.[/dim]"
1151
+ )
1152
+ developer_key = typer.prompt("Developer key", hide_input=True).strip()
1153
+ if not developer_key:
1154
+ console.print("[red]No key provided.[/red]")
1155
+ raise typer.Exit(code=1)
1156
+
1157
+ client = PlatformClient()
1158
+ try:
1159
+ payload = client.validate_key(developer_key)
1160
+ except PlatformError as exc:
1161
+ console.print(f"[red]Login failed:[/red] {exc}")
1162
+ raise typer.Exit(code=1) from exc
1163
+ if not payload.get("valid", False):
1164
+ console.print("[red]Login failed:[/red] key is invalid or revoked.")
1165
+ raise typer.Exit(code=1)
1166
+
1167
+ creds = load_credentials()
1168
+ creds.update(
1169
+ {
1170
+ "developer_key": developer_key,
1171
+ "account_id": payload.get("account_id"),
1172
+ "active_account_id": payload.get("account_id"),
1173
+ "active_account_slug": payload.get("account_slug"),
1174
+ "platform_url": platform_url(),
1175
+ "masked_key": mask_key(developer_key),
1176
+ }
1177
+ )
1178
+ save_credentials(creds)
1179
+ console.print(
1180
+ f"[green]Logged in[/green] as account "
1181
+ f"[bold]{payload.get('account_id', 'unknown')}[/bold] ({mask_key(developer_key)})."
1182
+ )
1183
+ return ensure_authenticated(project_root, force_remote=False)
1184
+
1185
+
1186
+ def _inject_project_id(config_text: str, project_id: str) -> str:
1187
+ """Persist the bound cloud ``project_id`` into refactor.config text.
1188
+
1189
+ Written as a top-level key so ``constitution.load_config`` exposes it under
1190
+ ``settings['project_id']`` (which the commit-loop telemetry reads). Inserted
1191
+ right after the ``version:`` line, or prepended if that anchor is absent.
1192
+ """
1193
+ line = f"project_id: {project_id}\n"
1194
+ anchor = "version: 1\n"
1195
+ if anchor in config_text:
1196
+ return config_text.replace(anchor, anchor + line, 1)
1197
+ return line + config_text
1198
+
1199
+
1084
1200
  def _offer_commit_hook(project_root: Path) -> None:
1085
1201
  """Offer to wire the local commit-review hook during `init` (docs/41 `R28`).
1086
1202
 
@@ -4518,7 +4634,34 @@ def _cloud_doctor(
4518
4634
  if execution_mode == "cloud_byok":
4519
4635
  if provider_key:
4520
4636
  # Env-key path: perform a lightweight inference ping so entitlement +
4521
- # provider auth are validated together.
4637
+ # provider auth are validated together. Forward the configured
4638
+ # ``model_id`` (already ${ENV} interpolated by the constitution) so the
4639
+ # ping validates the *same* model real runs use. Doctor must not let the
4640
+ # backend/provider silently choose a default model because that can pass
4641
+ # while the configured run model is missing or unreachable.
4642
+ model_hint = str(constitution.get_setting("model_id", "") or "").strip()
4643
+ if not model_hint:
4644
+ console.print(
4645
+ "[red]model[/red]: no `model_id` configured in refactor.config."
4646
+ )
4647
+ console.print(
4648
+ "[yellow]doctor[/yellow]: you need to set `model_id` in "
4649
+ "refactor.config for the selected provider before doctor can "
4650
+ "validate model reachability."
4651
+ )
4652
+ raise typer.Exit(code=1)
4653
+ console.print(f"[bold]model[/bold]: {model_hint}")
4654
+ payload = {
4655
+ "stage": "review",
4656
+ "execution_mode": "cloud_byok",
4657
+ "provider": provider_name,
4658
+ "provider_key": provider_key,
4659
+ "constitution_hash": "doctor",
4660
+ "system_prompt": "doctor ping",
4661
+ "user_prompt": "doctor ping",
4662
+ "max_alerts": 1,
4663
+ "model_hint": model_hint,
4664
+ }
4522
4665
  try:
4523
4666
  response = httpx.post(
4524
4667
  f"{platform_url()}/v1/inference/refactor",
@@ -4527,16 +4670,7 @@ def _cloud_doctor(
4527
4670
  "Content-Type": "application/json",
4528
4671
  "Idempotency-Key": f"doctor-cloud-byok-{uuid.uuid4().hex}",
4529
4672
  },
4530
- json={
4531
- "stage": "review",
4532
- "execution_mode": "cloud_byok",
4533
- "provider": provider_name,
4534
- "provider_key": provider_key,
4535
- "constitution_hash": "doctor",
4536
- "system_prompt": "doctor ping",
4537
- "user_prompt": "doctor ping",
4538
- "max_alerts": 1,
4539
- },
4673
+ json=payload,
4540
4674
  timeout=20.0,
4541
4675
  )
4542
4676
  except httpx.HTTPError as exc:
@@ -4561,6 +4695,11 @@ def _cloud_doctor(
4561
4695
  f"[red]cloud BYOK ping failed[/red] ({response.status_code})"
4562
4696
  + (f": {detail}" if detail else ".")
4563
4697
  )
4698
+ console.print(
4699
+ "[yellow]doctor[/yellow]: could not reach the configured model. "
4700
+ "Set a reachable `model_id` in refactor.config for the selected "
4701
+ "provider, then rerun `refactor doctor`."
4702
+ )
4564
4703
  raise typer.Exit(code=1)
4565
4704
  console.print("[green]cloud endpoint[/green]: reachable.")
4566
4705
  console.print("[green]BYOK provider auth[/green]: OK")
@@ -316,6 +316,20 @@ def _load_initial_events(project_root: Path, since: str | None, limit: int) -> l
316
316
  return events
317
317
 
318
318
 
319
+ def _report_node(project_root: Path, node) -> None:
320
+ """Best-effort opt-in commit telemetry for a node transition (docs/44 Phase 3).
321
+
322
+ Fail-open: no-ops instantly unless the repo is bound to a platform project
323
+ (``REFACTOR_PROJECT_ID`` / ``project_id`` in config) and never raises.
324
+ """
325
+ try:
326
+ from refactorai_cli import commit_telemetry
327
+
328
+ commit_telemetry.emit_node(project_root, node)
329
+ except Exception:
330
+ pass
331
+
332
+
319
333
  def process_pending(
320
334
  project_root: Path,
321
335
  state: WatchState,
@@ -349,6 +363,7 @@ def process_pending(
349
363
  state.mark_reviewing(node.sha)
350
364
  if on_update:
351
365
  on_update(state)
366
+ _report_node(project_root, node)
352
367
  intent = intent_for(node.sha) if intent_for else None
353
368
  outcome = runner.run(
354
369
  sha=node.sha,
@@ -361,6 +376,7 @@ def process_pending(
361
376
  reviewed += 1
362
377
  if on_update:
363
378
  on_update(state)
379
+ _report_node(project_root, node)
364
380
  # docs/43 Phase 1: fold current refactor-branch data into reviewed nodes so
365
381
  # their phase (review/finalize/done) reflects any branch built by `[r]`.
366
382
  refresh_finalize(project_root, state)
@@ -765,6 +781,7 @@ def watch(
765
781
 
766
782
  project_root = _resolve_repo(Path.cwd())
767
783
  _ensure_auth_in_window(json_output)
784
+ _report_heartbeat(project_root)
768
785
 
769
786
  state = WatchState(repo_name=project_root.name)
770
787
  try:
@@ -814,6 +831,23 @@ def _open_watch_window(
814
831
  console.print("[dim]watch: closed (the commit hook/queue are unaffected).[/dim]")
815
832
 
816
833
 
834
+ def _report_heartbeat(project_root: Path) -> None:
835
+ """Best-effort opt-in watch liveness ping (docs/44 Phase 3). Never raises."""
836
+ try:
837
+ import socket
838
+
839
+ from refactorai_cli import commit_telemetry
840
+
841
+ branch = ""
842
+ try:
843
+ branch = git_scope.current_branch(project_root) or ""
844
+ except Exception:
845
+ branch = ""
846
+ commit_telemetry.post_heartbeat(project_root, host=socket.gethostname(), branch=branch)
847
+ except Exception:
848
+ pass
849
+
850
+
817
851
  def _load_watch_config(project_root: Path) -> dict:
818
852
  """Best-effort project-config settings dict (never raises)."""
819
853
  try:
@@ -0,0 +1,196 @@
1
+ """Commit-loop telemetry: build + post commit events to the platform (docs/44 Phase 3).
2
+
3
+ The local loop is authoritative and offline-first (docs/41 Part G): telemetry is
4
+ strictly opt-in and *fail-open*. Every function here swallows errors and returns a
5
+ falsey result rather than raising, so a network hiccup, missing key, or unbound
6
+ repo can never delay or break a commit / review / push.
7
+
8
+ Telemetry only activates when the repo is bound to a platform project — resolved
9
+ from ``REFACTOR_PROJECT_ID`` or a ``project_id`` setting in ``refactor.config`` —
10
+ and can be turned off entirely with ``REFACTOR_NO_TELEMETRY=1``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from pathlib import Path
17
+
18
+ import httpx
19
+
20
+ from refactorai_cli import __version__
21
+ from refactorai_cli.settings import platform_url
22
+
23
+ ENV_PROJECT_ID = "REFACTOR_PROJECT_ID"
24
+ ENV_DISABLE = "REFACTOR_NO_TELEMETRY"
25
+ WATCH_VERSION = __version__
26
+
27
+ _TRUTHY = {"1", "true", "yes", "on"}
28
+
29
+ # Local watch-node states -> server commit-event states.
30
+ _STATE_MAP = {
31
+ "queued": "queued",
32
+ "reviewing": "reviewing",
33
+ "reviewed": "reviewed",
34
+ "resolved": "resolved",
35
+ "pushed": "pushed",
36
+ "error": "error",
37
+ }
38
+ # Local gate labels -> server gate_status enum.
39
+ _GATE_MAP = {
40
+ "": "unknown",
41
+ "unknown": "unknown",
42
+ "passed": "passed",
43
+ "pass": "passed",
44
+ "warn": "warn",
45
+ "warning": "warn",
46
+ "high": "high",
47
+ "blocked": "high",
48
+ }
49
+
50
+
51
+ def telemetry_disabled() -> bool:
52
+ return str(os.environ.get(ENV_DISABLE, "")).strip().lower() in _TRUTHY
53
+
54
+
55
+ def _load_settings(project_root: Path | str) -> dict:
56
+ """Best-effort project-config settings dict (never raises)."""
57
+ try:
58
+ from refactor_core.constitution import (
59
+ CONFIG_FILENAME,
60
+ find_project_files,
61
+ load_config,
62
+ )
63
+
64
+ consti_path, _ = find_project_files(Path(project_root))
65
+ if consti_path is None:
66
+ return {}
67
+ cfg = load_config(consti_path.parent / CONFIG_FILENAME)
68
+ return dict(getattr(cfg, "settings", {}) or {})
69
+ except Exception:
70
+ return {}
71
+
72
+
73
+ def resolve_project_id(project_root: Path | str, config: dict | None = None) -> str:
74
+ """Resolve the bound platform project id, or ``""`` when unbound."""
75
+ env_pid = os.environ.get(ENV_PROJECT_ID, "").strip()
76
+ if env_pid:
77
+ return env_pid
78
+ settings = config if isinstance(config, dict) else _load_settings(project_root)
79
+ value = settings.get("project_id") if isinstance(settings, dict) else None
80
+ return str(value).strip() if value else ""
81
+
82
+
83
+ def build_commit_event(node_contract: dict, *, source: str = "local_commit") -> dict:
84
+ """Map a watch node contract (``watch_state.to_contract``) to the ingest body."""
85
+ nc = node_contract or {}
86
+ scope = nc.get("scope") or {}
87
+ gate = nc.get("gate") or {}
88
+ state = _STATE_MAP.get(str(nc.get("state") or "").strip().lower(), "reviewed")
89
+ gate_status = _GATE_MAP.get(str(gate.get("status") or "").strip().lower(), "unknown")
90
+ return {
91
+ "sha": str(nc.get("sha") or ""),
92
+ "branch": str(nc.get("branch") or ""),
93
+ "subject": str(nc.get("subject") or ""),
94
+ "intent_summary": str(nc.get("intent_summary") or ""),
95
+ "state": state,
96
+ "gate_status": gate_status,
97
+ "high_findings": int(gate.get("high_findings") or 0),
98
+ "refactor_request_count": int(nc.get("refactor_requests") or 0),
99
+ "changed_files": int(scope.get("changed") or 0),
100
+ "associated_files": int(scope.get("associated") or 0),
101
+ "push_eligible": bool(nc.get("push_eligible", False)),
102
+ "source": source,
103
+ }
104
+
105
+
106
+ def build_commit_event_from_queue(event: dict) -> dict:
107
+ """Map a raw commit-queue event (post-commit hook) to a ``queued`` ingest body."""
108
+ ev = event or {}
109
+ changed = ev.get("changed_files")
110
+ if isinstance(changed, list):
111
+ changed_count = len(changed)
112
+ else:
113
+ try:
114
+ changed_count = int(changed or 0)
115
+ except (TypeError, ValueError):
116
+ changed_count = 0
117
+ return {
118
+ "sha": str(ev.get("sha") or ""),
119
+ "branch": str(ev.get("branch") or ""),
120
+ "subject": str(ev.get("subject") or ""),
121
+ "intent_summary": "",
122
+ "state": "queued",
123
+ "gate_status": "unknown",
124
+ "high_findings": 0,
125
+ "refactor_request_count": 0,
126
+ "changed_files": changed_count,
127
+ "associated_files": 0,
128
+ "push_eligible": False,
129
+ "source": str(ev.get("trigger_source") or "local_commit"),
130
+ }
131
+
132
+
133
+ def _auth_and_project(project_root: Path | str, config: dict | None) -> tuple[object | None, str]:
134
+ if telemetry_disabled():
135
+ return None, ""
136
+ project_id = resolve_project_id(project_root, config)
137
+ if not project_id:
138
+ return None, ""
139
+ try:
140
+ from refactorai_cli.auth import ensure_authenticated
141
+
142
+ auth = ensure_authenticated(Path(project_root))
143
+ except Exception:
144
+ return None, ""
145
+ return auth, project_id
146
+
147
+
148
+ def _post(url: str, key: str, body: dict, timeout: float) -> bool:
149
+ try:
150
+ resp = httpx.post(
151
+ url,
152
+ headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
153
+ json=body,
154
+ timeout=timeout,
155
+ )
156
+ return resp.status_code < 400
157
+ except Exception:
158
+ return False
159
+
160
+
161
+ def post_commit_event(
162
+ project_root: Path | str, body: dict, *, config: dict | None = None, timeout: float = 8.0
163
+ ) -> bool:
164
+ """Upsert one commit event. Best-effort + fail-open (returns success flag)."""
165
+ auth, project_id = _auth_and_project(project_root, config)
166
+ if not auth or not project_id:
167
+ return False
168
+ url = f"{platform_url()}/v1/projects/{project_id}/commit-events"
169
+ return _post(url, auth.key.key, body, timeout)
170
+
171
+
172
+ def post_heartbeat(
173
+ project_root: Path | str,
174
+ *,
175
+ host: str = "",
176
+ branch: str = "",
177
+ watch_version: str = WATCH_VERSION,
178
+ config: dict | None = None,
179
+ timeout: float = 8.0,
180
+ ) -> bool:
181
+ """Report a ``refactor watch`` liveness ping. Best-effort + fail-open."""
182
+ auth, project_id = _auth_and_project(project_root, config)
183
+ if not auth or not project_id:
184
+ return False
185
+ url = f"{platform_url()}/v1/projects/{project_id}/watch/heartbeat"
186
+ body = {"host": host, "branch": branch, "watch_version": watch_version}
187
+ return _post(url, auth.key.key, body, timeout)
188
+
189
+
190
+ def emit_node(project_root: Path | str, node, *, config: dict | None = None) -> bool:
191
+ """Build + post a commit event from a watch ``CommitNode``. Never raises."""
192
+ try:
193
+ body = build_commit_event(node.to_contract())
194
+ return post_commit_event(project_root, body, config=config)
195
+ except Exception:
196
+ return False
@@ -16,6 +16,7 @@ from refactorai_cli.commands import (
16
16
  engine_cmds,
17
17
  hook_cmds,
18
18
  model_cmds,
19
+ pre_push_cmds,
19
20
  request_cmds,
20
21
  runtime_cmds,
21
22
  runtime_proxy_cmds,
@@ -155,6 +156,11 @@ app.add_typer(toolchains_cmds.app, name="toolchains")
155
156
  # Local commit-triggered review hook (R28 Phase 2, docs/41).
156
157
  app.add_typer(hook_cmds.app, name="hook")
157
158
 
159
+ # Pre-push quality gate (docs/44 Phase 2); invoked by the git pre-push hook.
160
+ app.command("pre-push", context_settings={"ignore_unknown_options": True, "allow_extra_args": True})(
161
+ pre_push_cmds.pre_push
162
+ )
163
+
158
164
  # Persistent Refactor Terminal (R28 Phase 3, docs/41).
159
165
  app.add_typer(watch_cmds.watch_app, name="watch")
160
166
 
@@ -0,0 +1,112 @@
1
+ """Pure pre-push gate evaluation (docs/44 Phase 2).
2
+
3
+ Dependency-free so it is trivially unit-testable and safe to import from the
4
+ ``refactor pre-push`` command. The command layer supplies the observed counts
5
+ (pending refactor requests, high-severity count, doctor status) and the resolved
6
+ policy; this module decides whether the push is allowed and why.
7
+
8
+ Modes:
9
+ - ``off`` — never inspect; always allow.
10
+ - ``warn`` — surface blockers but always allow the push (never exit non-zero).
11
+ - ``strict`` — block the push when a selected condition is unmet, unless the
12
+ developer uses the escape hatch (``REFACTOR_SKIP``) and policy permits it.
13
+
14
+ The gate is fail-open by contract: the *command* wraps evaluation in a
15
+ try/except and allows the push on any unexpected error. This module only encodes
16
+ the policy decision for well-formed inputs.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+
23
+ VALID_MODES = ("off", "warn", "strict")
24
+
25
+ DEFAULT_POLICY: dict = {
26
+ "mode": "warn",
27
+ "block_on": ["high_severity", "unresolved_requests"],
28
+ "allow_skip": True,
29
+ "require_skip_reason": False,
30
+ "audit_skips": True,
31
+ }
32
+
33
+
34
+ @dataclass
35
+ class GateDecision:
36
+ allow: bool
37
+ mode: str
38
+ reasons: list[str] = field(default_factory=list)
39
+ skipped: bool = False
40
+ skip_reason: str = ""
41
+
42
+
43
+ def _normalize_mode(policy: dict) -> str:
44
+ mode = str((policy or {}).get("mode") or "warn").strip().lower()
45
+ return mode if mode in VALID_MODES else "warn"
46
+
47
+
48
+ def blocking_reasons(
49
+ policy: dict,
50
+ *,
51
+ open_requests: int,
52
+ high_severity_open: int,
53
+ doctor_failed: bool = False,
54
+ ) -> list[str]:
55
+ """Reasons the push would be gated, independent of mode/escape-hatch."""
56
+ block_on = set((policy or {}).get("block_on") or [])
57
+ reasons: list[str] = []
58
+ if "high_severity" in block_on and high_severity_open > 0:
59
+ reasons.append(
60
+ f"{high_severity_open} high-severity refactor request(s) still open"
61
+ )
62
+ if "unresolved_requests" in block_on and open_requests > 0:
63
+ reasons.append(f"{open_requests} unresolved refactor request(s)")
64
+ if "failed_doctor" in block_on and doctor_failed:
65
+ reasons.append("engine doctor check is failing")
66
+ return reasons
67
+
68
+
69
+ def evaluate(
70
+ policy: dict,
71
+ *,
72
+ open_requests: int,
73
+ high_severity_open: int,
74
+ doctor_failed: bool = False,
75
+ skip_requested: bool = False,
76
+ skip_reason: str = "",
77
+ ) -> GateDecision:
78
+ """Decide whether a push is allowed under ``policy``."""
79
+ mode = _normalize_mode(policy)
80
+ reasons = blocking_reasons(
81
+ policy,
82
+ open_requests=open_requests,
83
+ high_severity_open=high_severity_open,
84
+ doctor_failed=doctor_failed,
85
+ )
86
+
87
+ if mode == "off" or not reasons:
88
+ return GateDecision(allow=True, mode=mode, reasons=[])
89
+
90
+ if mode == "warn":
91
+ # Surface the blockers but never stop the push.
92
+ return GateDecision(allow=True, mode=mode, reasons=reasons)
93
+
94
+ # strict
95
+ allow_skip = bool((policy or {}).get("allow_skip", True))
96
+ require_reason = bool((policy or {}).get("require_skip_reason", False))
97
+ if skip_requested and allow_skip:
98
+ if require_reason and not str(skip_reason or "").strip():
99
+ return GateDecision(
100
+ allow=False,
101
+ mode=mode,
102
+ reasons=reasons + ["bypass requires a reason (set REFACTOR_SKIP_REASON)"],
103
+ )
104
+ return GateDecision(
105
+ allow=True,
106
+ mode=mode,
107
+ reasons=reasons,
108
+ skipped=True,
109
+ skip_reason=str(skip_reason or "").strip(),
110
+ )
111
+
112
+ return GateDecision(allow=False, mode=mode, reasons=reasons)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.4.16
3
+ Version: 0.5.0
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.2.9
11
+ Requires-Dist: refactorai-core>=3.2.11
12
12
 
13
13
  # refactorai-cli
14
14
 
@@ -5,6 +5,7 @@ refactorai_cli/auth.py
5
5
  refactorai_cli/client.py
6
6
  refactorai_cli/cloud_rr.py
7
7
  refactorai_cli/commit_queue.py
8
+ refactorai_cli/commit_telemetry.py
8
9
  refactorai_cli/control_plane.py
9
10
  refactorai_cli/credentials.py
10
11
  refactorai_cli/dotenv_loader.py
@@ -14,6 +15,7 @@ refactorai_cli/local_engine_runtime.py
14
15
  refactorai_cli/local_paths.py
15
16
  refactorai_cli/main.py
16
17
  refactorai_cli/model_policy.py
18
+ refactorai_cli/pre_push_gate.py
17
19
  refactorai_cli/refactor_branch_store.py
18
20
  refactorai_cli/review_runner.py
19
21
  refactorai_cli/runtime_manager.py
@@ -35,6 +37,7 @@ refactorai_cli/commands/cloud_cmds.py
35
37
  refactorai_cli/commands/engine_cmds.py
36
38
  refactorai_cli/commands/hook_cmds.py
37
39
  refactorai_cli/commands/model_cmds.py
40
+ refactorai_cli/commands/pre_push_cmds.py
38
41
  refactorai_cli/commands/request_cmds.py
39
42
  refactorai_cli/commands/rules_cmds.py
40
43
  refactorai_cli/commands/run_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.2.9
5
+ refactorai-core>=3.2.11