refactorai-cli 0.6.0__tar.gz → 0.7.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 (52) hide show
  1. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/PKG-INFO +1 -1
  2. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/pyproject.toml +1 -1
  3. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/__init__.py +1 -1
  4. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/cloud_rr.py +290 -30
  5. refactorai_cli-0.7.0/refactorai_cli/commands/workspace_cmds.py +79 -0
  6. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/main.py +4 -0
  7. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli.egg-info/PKG-INFO +1 -1
  8. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli.egg-info/SOURCES.txt +2 -1
  9. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/README.md +0 -0
  10. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/auth.py +0 -0
  11. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/client.py +0 -0
  12. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/commands/__init__.py +0 -0
  13. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/commands/account_cmds.py +0 -0
  14. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/commands/auth_cmds.py +0 -0
  15. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/commands/branch_cmds.py +0 -0
  16. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/commands/cloud_cmds.py +0 -0
  17. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/commands/engine_cmds.py +0 -0
  18. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/commands/hook_cmds.py +0 -0
  19. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/commands/model_cmds.py +0 -0
  20. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/commands/pre_push_cmds.py +0 -0
  21. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/commands/request_cmds.py +0 -0
  22. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/commands/rules_cmds.py +0 -0
  23. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/commands/run_cmds.py +0 -0
  24. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/commands/runtime_cmds.py +0 -0
  25. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
  26. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/commands/setup_cmds.py +0 -0
  27. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/commands/toolchains_cmds.py +0 -0
  28. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/commands/watch_cmds.py +0 -0
  29. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/commit_queue.py +0 -0
  30. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/commit_telemetry.py +0 -0
  31. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/control_plane.py +0 -0
  32. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/credentials.py +0 -0
  33. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/dotenv_loader.py +0 -0
  34. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/git_scope.py +0 -0
  35. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/local_constitution.py +0 -0
  36. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/local_engine_runtime.py +0 -0
  37. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/local_paths.py +0 -0
  38. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/model_policy.py +0 -0
  39. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/pre_push_gate.py +0 -0
  40. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/refactor_branch_store.py +0 -0
  41. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/review_runner.py +0 -0
  42. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/runtime_manager.py +0 -0
  43. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/settings.py +0 -0
  44. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/setup_flow.py +0 -0
  45. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/watch_ledger.py +0 -0
  46. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/watch_state.py +0 -0
  47. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli/watch_supervisor.py +0 -0
  48. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli.egg-info/dependency_links.txt +0 -0
  49. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli.egg-info/entry_points.txt +0 -0
  50. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli.egg-info/requires.txt +0 -0
  51. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/refactorai_cli.egg-info/top_level.txt +0 -0
  52. {refactorai_cli-0.6.0 → refactorai_cli-0.7.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Local-first CLI for the refactor platform
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "refactorai-cli"
3
- version = "0.6.0"
3
+ version = "0.7.0"
4
4
  description = "Local-first CLI for the refactor platform"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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.6.0"
8
+ __version__ = "0.7.0"
@@ -68,6 +68,13 @@ from refactorai_cli.auth import ensure_authenticated
68
68
  from refactorai_cli.settings import platform_url
69
69
 
70
70
  _ENV_ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*$")
71
+ _WORKSPACE_DIRNAME = "workspace"
72
+ _WORKSPACE_NAME = "host"
73
+ _WORKSPACE_CACHE_DIRNAME = "cache"
74
+ # Directory names never mirrored into (or pruned from) the managed workspace:
75
+ # git metadata, the central store, and heavy dependency dirs that are restored
76
+ # in-workspace and cached across runs.
77
+ _WORKSPACE_EXCLUDE = {".git", ".refactor", "node_modules"}
71
78
 
72
79
  # Manifest/config files that reveal how a project runs its tests. Sent (truncated)
73
80
  # to the server for agentic test-command discovery when deterministic detection
@@ -105,7 +112,7 @@ def _read_source(project_root: Path, rel: str) -> str:
105
112
 
106
113
 
107
114
  def _run_test(
108
- project_root: Path, test_command: str | None, *, env: dict | None = None
115
+ execution_root: Path, test_command: str | None, *, env: dict | None = None
109
116
  ) -> tuple[bool, str]:
110
117
  """Run the configured test command on the host; (passed, detail).
111
118
 
@@ -127,7 +134,7 @@ def _run_test(
127
134
  proc = subprocess.run(
128
135
  command,
129
136
  shell=True,
130
- cwd=str(project_root),
137
+ cwd=str(execution_root),
131
138
  capture_output=True,
132
139
  text=True,
133
140
  timeout=600,
@@ -141,7 +148,7 @@ def _run_test(
141
148
 
142
149
 
143
150
  def _run_command_with_progress(
144
- project_root: Path,
151
+ execution_root: Path,
145
152
  command: str,
146
153
  *,
147
154
  env: dict | None = None,
@@ -158,7 +165,7 @@ def _run_command_with_progress(
158
165
  proc = subprocess.Popen(
159
166
  command,
160
167
  shell=True,
161
- cwd=str(project_root),
168
+ cwd=str(execution_root),
162
169
  stdout=subprocess.PIPE,
163
170
  stderr=subprocess.STDOUT,
164
171
  text=True,
@@ -403,13 +410,24 @@ def _write_json_atomic(path: Path, payload: dict) -> None:
403
410
 
404
411
 
405
412
  def _node_lockfile(project_root: Path) -> Path | None:
406
- for name in ("package-lock.json", "npm-shrinkwrap.json"):
413
+ for name in ("pnpm-lock.yaml", "yarn.lock", "package-lock.json", "npm-shrinkwrap.json"):
407
414
  path = project_root / name
408
415
  if path.is_file():
409
416
  return path
410
417
  return None
411
418
 
412
419
 
420
+ def _node_install_command(project_root: Path) -> tuple[str, str]:
421
+ lockfile = _node_lockfile(project_root)
422
+ if lockfile is None:
423
+ return "npm", "npm install"
424
+ if lockfile.name == "pnpm-lock.yaml":
425
+ return "corepack", "corepack pnpm install --frozen-lockfile"
426
+ if lockfile.name == "yarn.lock":
427
+ return "corepack", "corepack yarn install --immutable"
428
+ return "npm", "npm ci"
429
+
430
+
413
431
  def _node_deps_state_path(project_root: Path) -> Path:
414
432
  return resolve_store_dir(project_root) / "toolchain-state" / "node-deps.json"
415
433
 
@@ -492,7 +510,7 @@ def _merge_toolchain_env(base: dict | None, new: dict) -> dict:
492
510
 
493
511
 
494
512
  def _provision_toolchain(
495
- project_root: Path,
513
+ execution_root: Path,
496
514
  test_command: str | None,
497
515
  config: dict,
498
516
  *,
@@ -517,6 +535,10 @@ def _provision_toolchain(
517
535
  from refactor_core.host_toolchain import binary_to_runtime, guidance_for_binary
518
536
 
519
537
  def _on_progress(msg: str) -> None:
538
+ # Warm-path reuse is expected on every run; suppress it to keep cloud RR
539
+ # progress noise low while still surfacing real downloads/extracts.
540
+ if str(msg).startswith("reusing "):
541
+ return
520
542
  _emit(progress_cb, phase="rr", event="toolchain_provisioning", detail=msg)
521
543
 
522
544
  merged_env: dict | None = None
@@ -546,7 +568,7 @@ def _provision_toolchain(
546
568
  continue
547
569
  _emit(progress_cb, phase="rr", event="toolchain_ready", runtime=runtime)
548
570
  merged_env = _merge_toolchain_env(merged_env, env)
549
- _restore_dependencies(project_root, runtime, merged_env, progress_cb, config=config)
571
+ _restore_dependencies(execution_root, runtime, merged_env, progress_cb, config=config)
550
572
 
551
573
  return merged_env, " ".join(n for n in notes if n).strip()
552
574
 
@@ -574,8 +596,7 @@ def _restore_dependencies(
574
596
  )
575
597
  return
576
598
  if runtime == "node" and (project_root / "package.json").is_file():
577
- lockfile = _node_lockfile(project_root)
578
- install_command = "npm ci" if lockfile is not None else "npm install"
599
+ install_binary, install_command = _node_install_command(project_root)
579
600
  marker_payload: dict | None = None
580
601
  if policy == "auto":
581
602
  should_restore, reason, marker_payload = _should_restore_node_dependencies(
@@ -595,6 +616,17 @@ def _restore_dependencies(
595
616
  event="toolchain_provisioning",
596
617
  detail=f"step 1/1: {install_command} (streaming output below)",
597
618
  )
619
+ if not _binary_on_path(install_binary, env=env):
620
+ _emit(
621
+ progress_cb,
622
+ phase="rr",
623
+ event="toolchain_provisioning",
624
+ detail=(
625
+ f"skipping node dependency restore: '{install_binary}' is not available "
626
+ f"for {install_command}"
627
+ ),
628
+ )
629
+ return
598
630
  ok, detail = _run_command_with_progress(
599
631
  project_root,
600
632
  install_command,
@@ -700,6 +732,184 @@ def _collect_test_manifests(
700
732
  return docs
701
733
 
702
734
 
735
+ def _workspace_root(project_root: Path) -> Path:
736
+ return resolve_store_dir(project_root) / _WORKSPACE_DIRNAME / _WORKSPACE_NAME
737
+
738
+
739
+ def _workspace_cache_root(project_root: Path) -> Path:
740
+ return resolve_store_dir(project_root) / _WORKSPACE_CACHE_DIRNAME
741
+
742
+
743
+ def _workspace_env(project_root: Path, base_env: dict | None) -> dict | None:
744
+ env = dict(base_env) if base_env is not None else None
745
+ cache_root = _workspace_cache_root(project_root)
746
+ npm_cache = cache_root / "npm"
747
+ yarn_cache = cache_root / "yarn"
748
+ pnpm_store = cache_root / "pnpm-store"
749
+ for directory in (npm_cache, yarn_cache, pnpm_store):
750
+ with contextlib.suppress(OSError):
751
+ directory.mkdir(parents=True, exist_ok=True)
752
+ if env is None:
753
+ return None
754
+ env.setdefault("npm_config_cache", str(npm_cache))
755
+ env.setdefault("YARN_CACHE_FOLDER", str(yarn_cache))
756
+ env.setdefault("PNPM_STORE_DIR", str(pnpm_store))
757
+ return env
758
+
759
+
760
+ def _has_excluded_component(rel: Path, exclude: set[str]) -> bool:
761
+ return any(part in exclude for part in rel.parts)
762
+
763
+
764
+ def _needs_copy(src: Path, dst: Path) -> bool:
765
+ """True when ``dst`` is missing or differs from ``src`` (size/mtime)."""
766
+ try:
767
+ s = src.stat()
768
+ except OSError:
769
+ return False
770
+ try:
771
+ d = dst.stat()
772
+ except OSError:
773
+ return True
774
+ return int(s.st_mtime) != int(d.st_mtime) or s.st_size != d.st_size
775
+
776
+
777
+ def _sync_working_tree(
778
+ src: Path, dst: Path, *, exclude: set[str], skip_abs: set[Path]
779
+ ) -> None:
780
+ """Incrementally mirror the working tree ``src`` into ``dst``.
781
+
782
+ Copies tracked *and* untracked files (so gitignored inputs a test needs — a
783
+ local ``.env``, fixtures — are present) while skipping heavy/managed dirs
784
+ (``exclude`` by name, ``skip_abs`` by absolute path). Unchanged files are
785
+ left alone (size+mtime check) so re-runs are cheap, and files deleted in the
786
+ source are removed from the mirror. Excluded dirs (e.g. the workspace's own
787
+ ``node_modules``) are never copied *or* deleted, so dependency caches persist.
788
+ """
789
+ src = Path(src)
790
+ dst = Path(dst)
791
+ dst.mkdir(parents=True, exist_ok=True)
792
+
793
+ seen_files: set[str] = set()
794
+ seen_dirs: set[str] = set()
795
+ for root, dirs, files in os.walk(src):
796
+ root_path = Path(root)
797
+ # Prune excluded/managed dirs in place so os.walk never descends them.
798
+ dirs[:] = [
799
+ d
800
+ for d in dirs
801
+ if d not in exclude and (root_path / d).resolve() not in skip_abs
802
+ ]
803
+ rel_root = root_path.relative_to(src)
804
+ for d in dirs:
805
+ rel = rel_root / d
806
+ seen_dirs.add(rel.as_posix())
807
+ with contextlib.suppress(OSError):
808
+ (dst / rel).mkdir(parents=True, exist_ok=True)
809
+ for f in files:
810
+ rel = rel_root / f
811
+ seen_files.add(rel.as_posix())
812
+ source_file = src / rel
813
+ target_file = dst / rel
814
+ if _needs_copy(source_file, target_file):
815
+ with contextlib.suppress(OSError):
816
+ target_file.parent.mkdir(parents=True, exist_ok=True)
817
+ shutil.copy2(source_file, target_file)
818
+
819
+ # Deletion phase: drop mirror entries whose source no longer exists, never
820
+ # touching anything under an excluded component (node_modules, caches, .git).
821
+ for root, dirs, files in os.walk(dst, topdown=False):
822
+ rel_root = Path(root).relative_to(dst)
823
+ if _has_excluded_component(rel_root, exclude):
824
+ continue
825
+ for f in files:
826
+ rel = rel_root / f
827
+ if _has_excluded_component(rel, exclude):
828
+ continue
829
+ if rel.as_posix() not in seen_files:
830
+ with contextlib.suppress(OSError):
831
+ (Path(root) / f).unlink()
832
+ for d in dirs:
833
+ if d in exclude:
834
+ continue
835
+ rel = rel_root / d
836
+ if rel.as_posix() not in seen_dirs:
837
+ with contextlib.suppress(OSError):
838
+ (Path(root) / d).rmdir()
839
+
840
+
841
+ def _prepare_managed_workspace(project_root: Path, *, progress_cb: Callable[[dict], None] | None) -> Path:
842
+ """Mirror the project's working tree into the managed workspace and return it.
843
+
844
+ The mirror (not a HEAD worktree) reflects exactly what the developer has on
845
+ disk, so behavior verification matches reality even with uncommitted or
846
+ gitignored files. Installs/tests run here; the real repo is never touched.
847
+ """
848
+ workspace = _workspace_root(project_root)
849
+ with contextlib.suppress(OSError):
850
+ workspace.parent.mkdir(parents=True, exist_ok=True)
851
+ skip_abs: set[Path] = set()
852
+ with contextlib.suppress(Exception):
853
+ skip_abs.add(resolve_store_dir(project_root).resolve())
854
+ with contextlib.suppress(Exception):
855
+ skip_abs.add(workspace.resolve())
856
+ try:
857
+ _sync_working_tree(
858
+ project_root, workspace, exclude=set(_WORKSPACE_EXCLUDE), skip_abs=skip_abs
859
+ )
860
+ except OSError as exc:
861
+ raise CloudRRError(f"failed to prepare managed workspace: {exc}") from exc
862
+ _emit(
863
+ progress_cb,
864
+ phase="rr",
865
+ event="workspace_ready",
866
+ detail=f"managed workspace ready at {workspace}",
867
+ )
868
+ return workspace
869
+
870
+
871
+ def prune_workspace(project_root: Path, *, include_cache: bool = False) -> list[str]:
872
+ """Remove the managed workspace mirror (and optionally its dep caches).
873
+
874
+ Returns the list of removed absolute paths. Safe to call when nothing has
875
+ been provisioned yet (returns an empty list).
876
+ """
877
+ removed: list[str] = []
878
+ base = resolve_store_dir(project_root) / _WORKSPACE_DIRNAME
879
+ if base.exists():
880
+ shutil.rmtree(base, ignore_errors=True)
881
+ removed.append(str(base))
882
+ if include_cache:
883
+ cache = _workspace_cache_root(project_root)
884
+ if cache.exists():
885
+ shutil.rmtree(cache, ignore_errors=True)
886
+ removed.append(str(cache))
887
+ return removed
888
+
889
+
890
+ def workspace_paths(project_root: Path) -> dict:
891
+ """Return the managed workspace + cache locations for ``project_root``."""
892
+ return {
893
+ "workspace": _workspace_root(project_root),
894
+ "cache": _workspace_cache_root(project_root),
895
+ }
896
+
897
+
898
+ def _git_status_porcelain(project_root: Path) -> str:
899
+ try:
900
+ proc = subprocess.run(
901
+ ["git", "-C", str(project_root), "status", "--porcelain"],
902
+ capture_output=True,
903
+ text=True,
904
+ timeout=30,
905
+ )
906
+ except (subprocess.SubprocessError, OSError):
907
+ return ""
908
+ if proc.returncode != 0:
909
+ return ""
910
+ return (proc.stdout or "").strip()
911
+
912
+
703
913
  def _discover_test_command_via_cloud(
704
914
  *,
705
915
  project_root: Path,
@@ -967,6 +1177,18 @@ def run_cloud_refactor_requests(
967
1177
  if requests is None:
968
1178
  requests = pending_requests(project_root)
969
1179
 
1180
+ run_store = create_run(project_root)
1181
+ run_id = run_store.run_id
1182
+ resolved_model = model_hint or str(
1183
+ constitution.get_setting("model_id", "managed-default") or "managed-default"
1184
+ )
1185
+ outcomes: list[dict] = []
1186
+ counts = {"applied": 0, "denied": 0, "failed": 0, "deferred": 0}
1187
+ notes: list[str] = []
1188
+ stopped = False
1189
+ stop_reason = ""
1190
+ model_id = resolved_model
1191
+
970
1192
  _emit(
971
1193
  progress_cb,
972
1194
  phase="rr",
@@ -975,18 +1197,51 @@ def run_cloud_refactor_requests(
975
1197
  test_command=test_command or "",
976
1198
  execution_env="cloud",
977
1199
  )
1200
+ execution_root = _prepare_managed_workspace(project_root, progress_cb=progress_cb)
1201
+ status_before = _git_status_porcelain(project_root)
978
1202
 
979
1203
  # Managed host toolchain: when the resolved command's runtime is missing from
980
1204
  # the host PATH, download a pinned runtime into the central store and run
981
1205
  # tests with it (no container, no system changes). ``toolchain_env`` is None
982
1206
  # when nothing was provisioned (already runnable / no managed runtime).
983
1207
  toolchain_env, toolchain_note = _provision_toolchain(
984
- project_root,
1208
+ execution_root,
985
1209
  test_command,
986
1210
  config,
987
1211
  consent_cb=toolchain_consent_cb,
988
1212
  progress_cb=progress_cb,
989
1213
  )
1214
+ toolchain_env = _workspace_env(project_root, toolchain_env)
1215
+ status_after_toolchain = _git_status_porcelain(project_root)
1216
+ if status_after_toolchain != status_before:
1217
+ reason = (
1218
+ "managed workspace invariant violated: toolchain/dependency setup changed the project "
1219
+ "working tree before any RR patch was applied; aborting run."
1220
+ )
1221
+ _emit(
1222
+ progress_cb,
1223
+ phase="rr",
1224
+ event="workspace_dirty_abort",
1225
+ reason=reason,
1226
+ )
1227
+ notes = []
1228
+ if testcmd_note:
1229
+ notes.append(testcmd_note)
1230
+ if toolchain_note:
1231
+ notes.append(toolchain_note)
1232
+ notes.append(reason)
1233
+ return _build_artifact(
1234
+ run_store,
1235
+ run_id,
1236
+ constitution,
1237
+ model_id,
1238
+ outcomes,
1239
+ counts,
1240
+ notes,
1241
+ stopped=True,
1242
+ stop_reason=reason,
1243
+ test_command=test_command,
1244
+ )
990
1245
 
991
1246
  # python3-only hosts: rewrite a bare `python` test command to `python3` when
992
1247
  # we did NOT provision a managed runtime (managed provisioning already
@@ -1003,23 +1258,10 @@ def run_cloud_refactor_requests(
1003
1258
  previous=test_command,
1004
1259
  )
1005
1260
  test_command = normalized
1006
-
1007
- run_store = create_run(project_root)
1008
- run_id = run_store.run_id
1009
- resolved_model = model_hint or str(
1010
- constitution.get_setting("model_id", "managed-default") or "managed-default"
1011
- )
1012
-
1013
- outcomes: list[dict] = []
1014
- counts = {"applied": 0, "denied": 0, "failed": 0, "deferred": 0}
1015
- notes: list[str] = []
1016
1261
  if testcmd_note:
1017
1262
  notes.append(testcmd_note)
1018
1263
  if toolchain_note:
1019
1264
  notes.append(toolchain_note)
1020
- stopped = False
1021
- stop_reason = ""
1022
- model_id = resolved_model
1023
1265
 
1024
1266
  if not requests:
1025
1267
  return _build_artifact(
@@ -1130,7 +1372,7 @@ def run_cloud_refactor_requests(
1130
1372
  current_rr_id = action.get("rr_id", "") or current_rr_id
1131
1373
  _emit(progress_cb, phase="rr", event="test_generating", rr_id=current_rr_id)
1132
1374
  _write_generated_test(project_root, current_rr_id, action, by_id)
1133
- passed, detail = _run_test(project_root, test_command, env=toolchain_env)
1375
+ passed, detail = _run_test(execution_root, test_command, env=toolchain_env)
1134
1376
  _emit(progress_cb, phase="rr", event="baseline_test", rr_id=current_rr_id,
1135
1377
  passed=passed, detail=detail,
1136
1378
  execution_env="host+toolchain" if toolchain_env else "host")
@@ -1207,7 +1449,12 @@ def run_cloud_refactor_requests(
1207
1449
  approved=True,
1208
1450
  )
1209
1451
  passed, detail, files = _apply_and_verify(
1210
- project_root, run_store.run_dir, diff_text, test_command, env=toolchain_env
1452
+ project_root,
1453
+ execution_root,
1454
+ run_store.run_dir,
1455
+ diff_text,
1456
+ test_command,
1457
+ env=toolchain_env,
1211
1458
  )
1212
1459
  pending.applied = True
1213
1460
  if passed:
@@ -1307,7 +1554,13 @@ def _write_generated_test(project_root: Path, rr_id: str, action: dict, by_id: d
1307
1554
 
1308
1555
 
1309
1556
  def _apply_and_verify(
1310
- project_root: Path, run_dir: Path, diff_text: str, test_command, *, env: dict | None = None
1557
+ project_root: Path,
1558
+ execution_root: Path,
1559
+ run_dir: Path,
1560
+ diff_text: str,
1561
+ test_command,
1562
+ *,
1563
+ env: dict | None = None,
1311
1564
  ) -> tuple[bool, str, list[str]]:
1312
1565
  """Apply the diff (with backup), run the test; revert on red.
1313
1566
 
@@ -1325,17 +1578,24 @@ def _apply_and_verify(
1325
1578
  if not changes:
1326
1579
  return False, "diff produced no file changes", []
1327
1580
  changed_files = list(changes.keys())
1581
+ verify_backup_dir = run_dir / "workspace-backup"
1582
+ verify_backup_dir.mkdir(parents=True, exist_ok=True)
1328
1583
  try:
1329
- apply_changes(project_root, run_dir, changes)
1584
+ apply_changes(execution_root, verify_backup_dir, changes)
1330
1585
  except Exception as exc:
1331
1586
  return False, f"apply failed: {exc}", changed_files
1332
- passed, detail = _run_test(project_root, test_command, env=env)
1587
+ passed, detail = _run_test(execution_root, test_command, env=env)
1333
1588
  if not passed:
1334
1589
  try:
1335
- revert_from_backup(project_root, run_dir)
1590
+ revert_from_backup(execution_root, verify_backup_dir)
1336
1591
  except Exception as exc: # keep going; report both
1337
1592
  detail = f"{detail}; revert error: {exc}"
1338
- return passed, detail, changed_files
1593
+ return passed, detail, changed_files
1594
+ try:
1595
+ apply_changes(project_root, run_dir, changes)
1596
+ except Exception as exc:
1597
+ return False, f"apply to project failed after successful verify: {exc}", changed_files
1598
+ return True, detail, changed_files
1339
1599
 
1340
1600
 
1341
1601
  def _record_outcome(project_root, by_id, rr_id, verdict, message, attempts,
@@ -0,0 +1,79 @@
1
+ """Managed per-project workspace commands: inspect and prune.
2
+
3
+ Cloud modes run dependency install + tests inside a managed workspace mirror
4
+ under the central store (``~/.refactor/projects/<id>/workspace``) so the real
5
+ repo is never mutated. These commands let developers see and reclaim that space.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ import typer
14
+ from rich.console import Console
15
+ from rich.table import Table
16
+
17
+ from refactorai_cli import cloud_rr
18
+
19
+ console = Console()
20
+ app = typer.Typer(help="Inspect and manage the per-project managed workspace.")
21
+
22
+
23
+ def _dir_size_bytes(path: Path) -> int:
24
+ total = 0
25
+ for root, _dirs, files in os.walk(path):
26
+ for name in files:
27
+ try:
28
+ total += (Path(root) / name).stat().st_size
29
+ except OSError:
30
+ continue
31
+ return total
32
+
33
+
34
+ def _human(num_bytes: int) -> str:
35
+ size = float(num_bytes)
36
+ for unit in ("B", "KB", "MB", "GB", "TB"):
37
+ if size < 1024 or unit == "TB":
38
+ return f"{size:.0f}{unit}" if unit == "B" else f"{size:.1f}{unit}"
39
+ size /= 1024
40
+ return f"{size:.1f}TB"
41
+
42
+
43
+ @app.command("list")
44
+ def list_workspace() -> None:
45
+ """Show the managed workspace + dependency cache for this project."""
46
+ paths = cloud_rr.workspace_paths(Path.cwd())
47
+ table = Table(title="Managed workspace")
48
+ table.add_column("kind")
49
+ table.add_column("exists")
50
+ table.add_column("size")
51
+ table.add_column("path", overflow="fold")
52
+ for kind in ("workspace", "cache"):
53
+ path = Path(paths[kind])
54
+ exists = path.is_dir()
55
+ size = _human(_dir_size_bytes(path)) if exists else "-"
56
+ table.add_row(kind, "yes" if exists else "no", size, str(path))
57
+ console.print(table)
58
+
59
+
60
+ @app.command("prune")
61
+ def prune_workspace(
62
+ cache: bool = typer.Option(
63
+ False, "--cache", help="Also remove the dependency cache (npm/yarn/pnpm)."
64
+ ),
65
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
66
+ ) -> None:
67
+ """Remove the managed workspace mirror to reclaim disk space."""
68
+ target = "workspace + dependency cache" if cache else "workspace"
69
+ if not yes:
70
+ confirm = typer.prompt(f"Remove managed {target} for this project? (y/N)", default="N")
71
+ if confirm.strip().lower() not in {"y", "yes"}:
72
+ console.print("[dim]Aborted.[/dim]")
73
+ raise typer.Exit(code=0)
74
+ removed = cloud_rr.prune_workspace(Path.cwd(), include_cache=cache)
75
+ if not removed:
76
+ console.print("[dim]Nothing to remove.[/dim]")
77
+ return
78
+ for path in removed:
79
+ console.print(f"[green]removed[/green] {path}")
@@ -23,6 +23,7 @@ from refactorai_cli.commands import (
23
23
  setup_cmds,
24
24
  toolchains_cmds,
25
25
  watch_cmds,
26
+ workspace_cmds,
26
27
  )
27
28
 
28
29
  app = typer.Typer(
@@ -153,6 +154,9 @@ app.add_typer(cloud_cmds.app, name="cloud")
153
154
  # Managed host toolchains (R24 Phase 2).
154
155
  app.add_typer(toolchains_cmds.app, name="toolchains")
155
156
 
157
+ # Managed per-project workspace (isolated install/test mirror).
158
+ app.add_typer(workspace_cmds.app, name="workspace")
159
+
156
160
  # Local commit-triggered review hook (R28 Phase 2, docs/41).
157
161
  app.add_typer(hook_cmds.app, name="hook")
158
162
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Local-first CLI for the refactor platform
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -46,4 +46,5 @@ refactorai_cli/commands/runtime_cmds.py
46
46
  refactorai_cli/commands/runtime_proxy_cmds.py
47
47
  refactorai_cli/commands/setup_cmds.py
48
48
  refactorai_cli/commands/toolchains_cmds.py
49
- refactorai_cli/commands/watch_cmds.py
49
+ refactorai_cli/commands/watch_cmds.py
50
+ refactorai_cli/commands/workspace_cmds.py
File without changes
File without changes