refactorai-cli 0.5.3__tar.gz → 0.5.6__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.5.3 → refactorai_cli-0.5.6}/PKG-INFO +1 -1
  2. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/pyproject.toml +1 -1
  3. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/__init__.py +1 -1
  4. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/cloud_rr.py +256 -58
  5. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/watch_cmds.py +70 -3
  6. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commit_telemetry.py +83 -0
  7. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/watch_state.py +15 -1
  8. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli.egg-info/PKG-INFO +1 -1
  9. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/README.md +0 -0
  10. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/auth.py +0 -0
  11. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/client.py +0 -0
  12. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/__init__.py +0 -0
  13. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/account_cmds.py +0 -0
  14. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/auth_cmds.py +0 -0
  15. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/branch_cmds.py +0 -0
  16. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/cloud_cmds.py +0 -0
  17. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/engine_cmds.py +0 -0
  18. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/hook_cmds.py +0 -0
  19. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/model_cmds.py +0 -0
  20. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/pre_push_cmds.py +0 -0
  21. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/request_cmds.py +0 -0
  22. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/rules_cmds.py +0 -0
  23. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/run_cmds.py +0 -0
  24. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/runtime_cmds.py +0 -0
  25. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
  26. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/setup_cmds.py +0 -0
  27. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commands/toolchains_cmds.py +0 -0
  28. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/commit_queue.py +0 -0
  29. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/control_plane.py +0 -0
  30. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/credentials.py +0 -0
  31. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/dotenv_loader.py +0 -0
  32. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/git_scope.py +0 -0
  33. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/local_constitution.py +0 -0
  34. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/local_engine_runtime.py +0 -0
  35. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/local_paths.py +0 -0
  36. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/main.py +0 -0
  37. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/model_policy.py +0 -0
  38. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/pre_push_gate.py +0 -0
  39. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/refactor_branch_store.py +0 -0
  40. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/review_runner.py +0 -0
  41. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/runtime_manager.py +0 -0
  42. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/settings.py +0 -0
  43. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/setup_flow.py +0 -0
  44. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli/watch_supervisor.py +0 -0
  45. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli.egg-info/SOURCES.txt +0 -0
  46. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli.egg-info/dependency_links.txt +0 -0
  47. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli.egg-info/entry_points.txt +0 -0
  48. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli.egg-info/requires.txt +0 -0
  49. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/refactorai_cli.egg-info/top_level.txt +0 -0
  50. {refactorai_cli-0.5.3 → refactorai_cli-0.5.6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.5.3
3
+ Version: 0.5.6
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.5.3"
3
+ version = "0.5.6"
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.5.3"
8
+ __version__ = "0.5.6"
@@ -25,6 +25,7 @@ from __future__ import annotations
25
25
  from collections import deque
26
26
  import contextlib
27
27
  import hashlib
28
+ import os
28
29
  import re
29
30
  import shlex
30
31
  import shutil
@@ -186,15 +187,55 @@ def _run_command_with_progress(
186
187
  return False, ("\n".join(tail))[-700:]
187
188
 
188
189
 
189
- def _leading_binary(command: str | None) -> str | None:
190
- """Return the leading executable of a simple command, or None.
190
+ # Splits a composite shell command into its simple segments. We intentionally
191
+ # keep this conservative (no support for redirections/heredocs) — those don't
192
+ # appear in generated/detected test commands.
193
+ _COMPOSITE_SPLIT_RE = re.compile(r"&&|\|\||;|\|")
194
+
195
+ # Interpreters that are functionally interchangeable for our generated test
196
+ # commands. A host with only ``python3`` must still satisfy a ``python`` command
197
+ # (and vice versa) instead of triggering a needless managed-runtime download.
198
+ _INTERPRETER_ALIASES = {"python": "python3", "python3": "python"}
199
+
191
200
 
192
- Returns None for composite/shell commands (``&&``, subshells, pipes) that
193
- can't be reduced to a single binary, and for env-only prefixes.
201
+ def _command_segments(command: str | None) -> list[str]:
202
+ """Split a (possibly composite) shell command into simple segments.
203
+
204
+ Splits on ``&&``/``||``/``;``/pipes, strips subshell parens, and drops pure
205
+ ``cd <dir>`` navigation segments so each returned segment begins with the
206
+ real executable it invokes (e.g. ``cd client && npm test`` -> ``["npm
207
+ test"]``). Used so preflight/provisioning see the *actual* runner binary of a
208
+ monorepo command instead of treating the whole thing as opaque.
209
+ """
210
+ if not command or not str(command).strip():
211
+ return []
212
+ segments: list[str] = []
213
+ for part in _COMPOSITE_SPLIT_RE.split(command):
214
+ seg = part.strip().strip("()").strip()
215
+ if not seg:
216
+ continue
217
+ try:
218
+ tokens = shlex.split(seg, posix=True)
219
+ except ValueError:
220
+ segments.append(seg)
221
+ continue
222
+ # Drop a bare `cd <dir>` navigation segment; it carries no binary.
223
+ stripped = list(tokens)
224
+ while stripped and _ENV_ASSIGN_RE.match(stripped[0]):
225
+ stripped.pop(0)
226
+ if stripped and stripped[0] == "cd":
227
+ continue
228
+ segments.append(seg)
229
+ return segments
230
+
231
+
232
+ def _simple_leading_binary(command: str | None) -> str | None:
233
+ """Leading executable of a *simple* (non-composite) command, or None.
234
+
235
+ Skips leading ``VAR=value`` env assignments. Does not attempt to parse
236
+ composite commands — callers use :func:`_required_binaries` for those.
194
237
  """
195
238
  if not command or not str(command).strip():
196
- return None
197
- if any(op in command for op in ("&&", "||", ";", "|", "(", ")")):
198
239
  return None
199
240
  try:
200
241
  tokens = shlex.split(command, posix=True)
@@ -207,29 +248,101 @@ def _leading_binary(command: str | None) -> str | None:
207
248
  return tokens[0]
208
249
 
209
250
 
251
+ def _required_binaries(command: str | None) -> list[str]:
252
+ """Distinct leading binaries required by a (possibly composite) command.
253
+
254
+ A monorepo runner like ``cd client && npm test && cd server && pytest`` needs
255
+ several runtimes; returning each lets preflight validate — and provisioning
256
+ cover — all of them instead of trusting the command blindly. Order preserved,
257
+ duplicates removed, ``cd`` ignored.
258
+ """
259
+ binaries: list[str] = []
260
+ for seg in _command_segments(command):
261
+ binary = _simple_leading_binary(seg)
262
+ if binary and binary != "cd" and binary not in binaries:
263
+ binaries.append(binary)
264
+ return binaries
265
+
266
+
267
+ def _leading_binary(command: str | None) -> str | None:
268
+ """Primary runner binary of a command (first of :func:`_required_binaries`).
269
+
270
+ Kept for callers that only need a single representative binary; unlike the
271
+ old implementation this is composite-aware (``cd x && npm test`` -> ``npm``).
272
+ """
273
+ required = _required_binaries(command)
274
+ return required[0] if required else None
275
+
276
+
277
+ def _binary_on_path(binary: str, *, env: dict | None = None) -> bool:
278
+ """True when ``binary`` (or an interchangeable interpreter alias) is runnable.
279
+
280
+ An absolute/relative path is checked on disk; a bare name is looked up on
281
+ ``PATH`` (optionally the provisioned ``env``'s PATH). ``python``/``python3``
282
+ are treated as interchangeable so a ``python3``-only host satisfies a
283
+ ``python`` command without provisioning a managed runtime.
284
+ """
285
+ if not binary:
286
+ return False
287
+ if "/" in binary:
288
+ return Path(binary).expanduser().exists()
289
+ search_path = (env or {}).get("PATH") if env else None
290
+ if shutil.which(binary, path=search_path) is not None:
291
+ return True
292
+ alias = _INTERPRETER_ALIASES.get(binary)
293
+ return bool(alias and shutil.which(alias, path=search_path) is not None)
294
+
295
+
210
296
  def _client_preflight_command(
211
297
  project_root: Path, command: str | None, *, env: dict | None = None
212
298
  ) -> bool:
213
- """Return True when ``command`` looks runnable on this host.
214
-
215
- Composite/shell commands (``&&``, subshells, pipes) run under ``shell=True``
216
- and can't be reduced to one binary, so we trust them and let the real exit
217
- code gate. Simple commands are validated by checking the leading binary is on
218
- PATH — this guards against running (and charging for) a server-suggested
219
- command whose toolchain isn't installed. ``env`` optionally supplies the
220
- PATH to check against (e.g. after a managed toolchain is provisioned).
299
+ """Return True when *every* binary ``command`` needs is runnable on this host.
300
+
301
+ Composite/shell commands run under ``shell=True``, but we no longer trust
302
+ them blindly: we extract each segment's leading binary (ignoring ``cd``) and
303
+ require all of them to be present on PATH. This is what stops an un-runnable
304
+ ``cd client && python ...`` from reaching the baseline and failing at runtime
305
+ with ``python: not found``. ``env`` optionally supplies the PATH to check
306
+ against (e.g. after a managed toolchain is provisioned).
221
307
  """
222
308
  if not command or not str(command).strip():
223
309
  return False
224
- if any(op in command for op in ("&&", "||", ";", "|", "(", ")")):
225
- return True
226
- binary = _leading_binary(command)
227
- if not binary:
310
+ required = _required_binaries(command)
311
+ if not required:
228
312
  return False
229
- if "/" in binary:
230
- return Path(binary).expanduser().exists()
313
+ return all(_binary_on_path(binary, env=env) for binary in required)
314
+
315
+
316
+ def _missing_binaries(command: str | None, *, env: dict | None = None) -> list[str]:
317
+ """Required binaries of ``command`` that are NOT runnable on this host."""
318
+ return [b for b in _required_binaries(command) if not _binary_on_path(b, env=env)]
319
+
320
+
321
+ def _normalize_command_interpreters(command: str | None, *, env: dict | None = None) -> str | None:
322
+ """Rewrite a bare ``python`` command token to ``python3`` when needed.
323
+
324
+ On ``python3``-only hosts (common on Debian/Ubuntu) a generated
325
+ ``python -m unittest ...`` command fails with ``python: not found`` even
326
+ though a perfectly good interpreter exists. When ``python`` is absent but
327
+ ``python3`` is present we rewrite the *command token* (word-boundary, never
328
+ touching ``python3`` or a ``/usr/bin/python`` path) so the baseline runs.
329
+ No-op when ``python`` is available (incl. via a provisioned toolchain).
330
+ """
331
+ if not command or not str(command).strip():
332
+ return command
231
333
  search_path = (env or {}).get("PATH") if env else None
232
- return shutil.which(binary, path=search_path) is not None
334
+ for src, dst in (("python", "python3"), ("python3", "python")):
335
+ if src not in command:
336
+ continue
337
+ if shutil.which(src, path=search_path) is not None:
338
+ continue
339
+ if shutil.which(dst, path=search_path) is None:
340
+ continue
341
+ # Replace ``src`` only when it is a standalone command token: not part of
342
+ # a longer word (python3/pythonic) and not a path segment (/usr/bin/...).
343
+ pattern = re.compile(rf"(?<![\w./-]){re.escape(src)}(?![\w])")
344
+ command = pattern.sub(dst, command)
345
+ return command
233
346
 
234
347
 
235
348
  def _managed_toolchain_enabled(config: dict) -> bool:
@@ -245,18 +358,47 @@ def _managed_toolchain_enabled(config: dict) -> bool:
245
358
 
246
359
 
247
360
  def _detected_is_provisionable(command: str | None) -> bool:
248
- """True when ``command``'s leading binary maps to a managed runtime.
361
+ """True when every missing binary of ``command`` maps to a managed runtime.
249
362
 
250
- Lets the resolver keep a detected command (e.g. ``npm test``) whose runtime
251
- is missing from the host PATH but which the managed toolchain can download,
252
- instead of discarding it and deferring the run.
363
+ Lets the resolver keep a detected command (e.g. ``cd client && npm test``)
364
+ whose runtime is missing from the host PATH but which the managed toolchain
365
+ can download, instead of discarding it and deferring the run. Requires *all*
366
+ missing binaries to be provisionable so a command that mixes a managed
367
+ runtime with an un-provisionable Tier-3 binary still falls through.
253
368
  """
254
369
  from refactor_core.host_toolchain import binary_to_runtime
255
370
 
256
- binary = _leading_binary(command)
257
- if not binary:
371
+ missing = _missing_binaries(command)
372
+ if not missing:
258
373
  return False
259
- return binary_to_runtime(binary) is not None
374
+ return all(binary_to_runtime(binary) is not None for binary in missing)
375
+
376
+
377
+ def _merge_toolchain_env(base: dict | None, new: dict) -> dict:
378
+ """Merge a freshly provisioned toolchain env into an accumulated one.
379
+
380
+ Composite commands can need several managed runtimes (e.g. ``node`` +
381
+ ``python``). Each ``provision_for_command`` call returns a full env built
382
+ from ``os.environ`` with its bin dir prepended to ``PATH``; naively using the
383
+ last one would drop earlier runtimes. This prepends the new toolchain's PATH
384
+ entries ahead of the accumulated PATH and carries over runtime-specific vars
385
+ (e.g. ``GOROOT``). ``base`` None => first provisioned runtime.
386
+ """
387
+ if base is None:
388
+ return dict(new)
389
+ merged = dict(base)
390
+ new_path = new.get("PATH", "")
391
+ base_path = base.get("PATH", "")
392
+ if new_path and new_path != base_path:
393
+ env_tail = os.environ.get("PATH", "")
394
+ prefix = new_path
395
+ if env_tail and new_path.endswith(env_tail):
396
+ prefix = new_path[: -len(env_tail)].rstrip(os.pathsep)
397
+ merged["PATH"] = f"{prefix}{os.pathsep}{base_path}" if prefix else base_path
398
+ for key, value in new.items():
399
+ if key != "PATH" and key not in os.environ:
400
+ merged[key] = value
401
+ return merged
260
402
 
261
403
 
262
404
  def _provision_toolchain(
@@ -267,45 +409,56 @@ def _provision_toolchain(
267
409
  consent_cb: Callable[[dict], bool] | None,
268
410
  progress_cb: Callable[[dict], None] | None,
269
411
  ) -> tuple[dict | None, str]:
270
- """Provision a managed runtime for ``test_command`` when it's missing locally.
271
-
272
- Returns ``(env, note)``. ``env`` (with the toolchain on ``PATH``) is returned
273
- only when a runtime was actually provisioned; otherwise ``None`` (the caller
274
- then runs with the inherited environment). No-ops when the command is already
275
- runnable, has no managed runtime, or provisioning is disabled/declined.
412
+ """Provision managed runtimes for every missing binary in ``test_command``.
413
+
414
+ Returns ``(env, note)``. ``env`` (with the toolchain(s) on ``PATH``) is
415
+ returned only when at least one runtime was actually provisioned; otherwise
416
+ ``None`` (the caller runs with the inherited environment). Composite commands
417
+ are handled per segment so a monorepo runner like ``cd client && npm test &&
418
+ cd server && pytest`` provisions BOTH node and python. No-ops when the command
419
+ is already runnable, has no managed runtime, or provisioning is disabled.
276
420
  """
277
421
  if not test_command or not _managed_toolchain_enabled(config):
278
422
  return None, ""
279
- if _client_preflight_command(project_root, test_command):
423
+ missing = _missing_binaries(test_command)
424
+ if not missing:
280
425
  return None, "" # already runnable on the host
281
- binary = _leading_binary(test_command)
282
- if not binary:
283
- return None, ""
284
- from refactor_core.host_toolchain import binary_to_runtime, guidance_for_binary
285
-
286
- runtime = binary_to_runtime(binary)
287
- if runtime is None:
288
- # No self-provisionable runtime -> Tier 3 guided-install guidance so the
289
- # developer gets an actionable message instead of a silent defer.
290
- guidance = guidance_for_binary(binary)
291
- if guidance:
292
- return None, f"'{binary}' is not installed and cannot be auto-provisioned. {guidance}"
293
- return None, ""
294
426
 
295
- if consent_cb is not None:
296
- approved = bool(consent_cb({"binary": binary, "runtime": runtime}))
297
- if not approved:
298
- return None, f"managed toolchain for '{binary}' declined; skipping provisioning."
427
+ from refactor_core.host_toolchain import binary_to_runtime, guidance_for_binary
299
428
 
300
429
  def _on_progress(msg: str) -> None:
301
430
  _emit(progress_cb, phase="rr", event="toolchain_provisioning", detail=msg)
302
431
 
303
- env, note = provision_for_command(binary, on_progress=_on_progress)
304
- if env is None:
305
- return None, note
306
- _emit(progress_cb, phase="rr", event="toolchain_ready", runtime=runtime)
307
- _restore_dependencies(project_root, runtime, env, progress_cb)
308
- return env, note
432
+ merged_env: dict | None = None
433
+ notes: list[str] = []
434
+ for binary in missing:
435
+ # A single provisioned runtime can satisfy several missing binaries
436
+ # (e.g. python also provides pip), so re-check against progress so far.
437
+ if merged_env is not None and _binary_on_path(binary, env=merged_env):
438
+ continue
439
+ runtime = binary_to_runtime(binary)
440
+ if runtime is None:
441
+ # No self-provisionable runtime -> Tier 3 guided-install guidance so
442
+ # the developer gets an actionable message instead of a silent defer.
443
+ guidance = guidance_for_binary(binary)
444
+ notes.append(
445
+ f"'{binary}' is not installed and cannot be auto-provisioned."
446
+ + (f" {guidance}" if guidance else "")
447
+ )
448
+ continue
449
+ if consent_cb is not None and not bool(consent_cb({"binary": binary, "runtime": runtime})):
450
+ notes.append(f"managed toolchain for '{binary}' declined; skipping provisioning.")
451
+ continue
452
+ env, note = provision_for_command(binary, on_progress=_on_progress)
453
+ if env is None:
454
+ if note:
455
+ notes.append(note)
456
+ continue
457
+ _emit(progress_cb, phase="rr", event="toolchain_ready", runtime=runtime)
458
+ merged_env = _merge_toolchain_env(merged_env, env)
459
+ _restore_dependencies(project_root, runtime, merged_env, progress_cb)
460
+
461
+ return merged_env, " ".join(n for n in notes if n).strip()
309
462
 
310
463
 
311
464
  def _restore_dependencies(
@@ -713,6 +866,22 @@ def run_cloud_refactor_requests(
713
866
  progress_cb=progress_cb,
714
867
  )
715
868
 
869
+ # python3-only hosts: rewrite a bare `python` test command to `python3` when
870
+ # we did NOT provision a managed runtime (managed provisioning already
871
+ # supplies a `python`). Prevents a `python: not found` baseline failure and
872
+ # the endless-defer loop it caused.
873
+ if toolchain_env is None and test_command:
874
+ normalized = _normalize_command_interpreters(test_command)
875
+ if normalized and normalized != test_command:
876
+ _emit(
877
+ progress_cb,
878
+ phase="rr",
879
+ event="testcmd_normalized",
880
+ test_command=normalized,
881
+ previous=test_command,
882
+ )
883
+ test_command = normalized
884
+
716
885
  run_store = create_run(project_root)
717
886
  run_id = run_store.run_id
718
887
  resolved_model = model_hint or str(
@@ -736,6 +905,35 @@ def run_cloud_refactor_requests(
736
905
  notes, stopped=False, stop_reason="", test_command=test_command,
737
906
  )
738
907
 
908
+ # Fail-fast: if a test command is configured but STILL not runnable after
909
+ # provisioning + interpreter normalization, stop up front with an actionable
910
+ # message instead of opening a session and deferring every RR behind an
911
+ # opaque `python: not found`. (A run with NO test command keeps prior
912
+ # behavior: the server decides per-RR whether it can proceed.)
913
+ if test_command and not _client_preflight_command(
914
+ project_root, test_command, env=toolchain_env
915
+ ):
916
+ missing = _missing_binaries(test_command, env=toolchain_env)
917
+ reason = (
918
+ "baseline test command is not runnable on this host "
919
+ f"(missing: {', '.join(missing) or 'required toolchain'}). "
920
+ "Install the toolchain or enable managed provisioning, then re-run. "
921
+ "No changes were attempted."
922
+ )
923
+ _emit(
924
+ progress_cb,
925
+ phase="rr",
926
+ event="preflight_failed",
927
+ reason=reason,
928
+ test_command=test_command,
929
+ missing=missing,
930
+ )
931
+ notes.append(reason)
932
+ return _build_artifact(
933
+ run_store, run_id, constitution, model_id, outcomes, counts,
934
+ notes, stopped=True, stop_reason=reason, test_command=test_command,
935
+ )
936
+
739
937
  session = _Session(
740
938
  auth_ctx=auth_ctx,
741
939
  execution_mode=execution_mode,
@@ -18,6 +18,7 @@ import os
18
18
  import select
19
19
  import subprocess
20
20
  import sys
21
+ import threading
21
22
  import time
22
23
  from pathlib import Path
23
24
  from typing import Callable
@@ -330,6 +331,21 @@ def _report_node(project_root: Path, node) -> None:
330
331
  pass
331
332
 
332
333
 
334
+ def _report_refactor_requests(project_root: Path) -> None:
335
+ """Best-effort sync of the local RR store to the platform Inbox (docs/44).
336
+
337
+ Fail-open like commit telemetry: no-ops unless the repo is bound to a project
338
+ and never raises. Called after any pass/action that can change RR state so the
339
+ cloud Inbox mirrors the developer's local requests.
340
+ """
341
+ try:
342
+ from refactorai_cli import commit_telemetry
343
+
344
+ commit_telemetry.post_refactor_requests(project_root)
345
+ except Exception:
346
+ pass
347
+
348
+
333
349
  def process_pending(
334
350
  project_root: Path,
335
351
  state: WatchState,
@@ -382,6 +398,8 @@ def process_pending(
382
398
  refresh_finalize(project_root, state)
383
399
  if on_update:
384
400
  on_update(state)
401
+ if reviewed:
402
+ _report_refactor_requests(project_root)
385
403
  return reviewed
386
404
 
387
405
 
@@ -574,6 +592,9 @@ def _run_action(
574
592
  refactor_branch_store.set_outcome(project_root, branch, refactor_branch_store.OUTCOME_DROPPED)
575
593
  _sync_current_branch(project_root, state)
576
594
  refresh_finalize(project_root, state)
595
+ # `[r]`/`[m]`/`[d]` can change RR status; mirror the local store to the Inbox.
596
+ if action in ("code", "merge", "drop"):
597
+ _report_refactor_requests(project_root)
577
598
  console.print("\n[dim]watch: returning to the Refactor Terminal...[/dim]")
578
599
  live.start()
579
600
 
@@ -609,6 +630,7 @@ def _run_revert_action(live, project_root: Path, node, state: WatchState) -> Non
609
630
  build_action_command("revert", node.sha, rr_id=rr_id), project_root
610
631
  )
611
632
  refresh_finalize(project_root, state)
633
+ _report_refactor_requests(project_root)
612
634
  else:
613
635
  console.print("[dim]watch: nothing reverted (out of range).[/dim]")
614
636
  else:
@@ -717,6 +739,46 @@ def _dispatch_key(live, project_root: Path, key: str, state: WatchState) -> None
717
739
  )
718
740
 
719
741
 
742
+ class _AnalysisAnimator:
743
+ """Repaint the Live graph on a timer so a blocking review animates.
744
+
745
+ While a commit is being analyzed the main thread is blocked inside
746
+ ``runner.run``; without this the "analyzing..." spinner on the reviewing node
747
+ would freeze and the terminal would look stuck. A daemon thread re-renders
748
+ the graph every ``interval`` seconds so the spinner keeps moving. Refreshes
749
+ share ``lock`` with the foreground refresh so Rich is never driven from two
750
+ threads at once.
751
+ """
752
+
753
+ def __init__(self, live, state: WatchState, lock: threading.Lock, *, interval: float = 0.08):
754
+ self._live = live
755
+ self._state = state
756
+ self._lock = lock
757
+ self._interval = interval
758
+ self._stop = threading.Event()
759
+ self._thread: threading.Thread | None = None
760
+
761
+ def __enter__(self) -> "_AnalysisAnimator":
762
+ self._thread = threading.Thread(target=self._run, name="watch-animator", daemon=True)
763
+ self._thread.start()
764
+ return self
765
+
766
+ def __exit__(self, *exc) -> bool:
767
+ self._stop.set()
768
+ if self._thread is not None:
769
+ self._thread.join(timeout=1.0)
770
+ return False
771
+
772
+ def _run(self) -> None:
773
+ while not self._stop.wait(self._interval):
774
+ with self._lock:
775
+ try:
776
+ self._live.update(render_graph(self._state))
777
+ self._live.refresh()
778
+ except Exception:
779
+ return
780
+
781
+
720
782
  def _run_tui(project_root: Path, state: WatchState, runner: ReviewRunner) -> None:
721
783
  """Render the live Graphite-style graph and consume the queue serially."""
722
784
  from refactorai_cli.main import _print_agent_intro
@@ -726,14 +788,19 @@ def _run_tui(project_root: Path, state: WatchState, runner: ReviewRunner) -> Non
726
788
 
727
789
  live = Live(render_graph(state), console=console, auto_refresh=False, screen=False)
728
790
  live.start()
791
+ refresh_lock = threading.Lock()
729
792
 
730
793
  def refresh(st: WatchState) -> None:
731
- live.update(render_graph(st))
732
- live.refresh()
794
+ with refresh_lock:
795
+ live.update(render_graph(st))
796
+ live.refresh()
733
797
 
734
798
  try:
735
799
  while True:
736
- process_pending(project_root, state, runner, on_update=refresh)
800
+ # Animate the spinner while reviews run (the review call blocks the
801
+ # main thread, so a background refresher drives the animation).
802
+ with _AnalysisAnimator(live, state, refresh_lock):
803
+ process_pending(project_root, state, runner, on_update=refresh)
737
804
  state.select_default()
738
805
  refresh(state)
739
806
 
@@ -47,6 +47,25 @@ _GATE_MAP = {
47
47
  "blocked": "high",
48
48
  }
49
49
 
50
+ # Local RR lifecycle states -> server RefactorRequest.status. The server Inbox
51
+ # treats {pending,in_progress,deferred,failed} as open; applied is terminal-done;
52
+ # denied is a human "won't fix" (mapped to suppressed); reverted re-opens the
53
+ # finding locally, so it maps back to pending.
54
+ _RR_STATUS_MAP = {
55
+ "pending": "pending",
56
+ "in_progress": "in_progress",
57
+ "deferred": "deferred",
58
+ "failed": "failed",
59
+ "applied": "applied",
60
+ "denied": "suppressed",
61
+ "reverted": "pending",
62
+ }
63
+ # Local RR states that imply a resolution outcome on the server row.
64
+ _RR_OUTCOME_MAP = {
65
+ "applied": "applied",
66
+ "denied": "suppressed",
67
+ }
68
+
50
69
 
51
70
  def telemetry_disabled() -> bool:
52
71
  return str(os.environ.get(ENV_DISABLE, "")).strip().lower() in _TRUTHY
@@ -130,6 +149,46 @@ def build_commit_event_from_queue(event: dict) -> dict:
130
149
  }
131
150
 
132
151
 
152
+ def build_refactor_request(rr) -> dict:
153
+ """Map a local ``refactor_core`` RefactorRequest to the RR ingest body.
154
+
155
+ Accepts the dataclass instance (uses attributes) and normalizes the local
156
+ lifecycle status/outcome to the server's enum. Attempts are flattened to a
157
+ compact per-proposal snapshot so the Inbox can show attempt history.
158
+ """
159
+ status = _RR_STATUS_MAP.get(str(getattr(rr, "status", "") or "").strip().lower(), "pending")
160
+ outcome = _RR_OUTCOME_MAP.get(str(getattr(rr, "status", "") or "").strip().lower())
161
+ attempts: list[dict] = []
162
+ for att in getattr(rr, "attempts", None) or []:
163
+ to_dict = getattr(att, "to_dict", None)
164
+ if callable(to_dict):
165
+ try:
166
+ attempts.append(to_dict())
167
+ continue
168
+ except Exception:
169
+ pass
170
+ if isinstance(att, dict):
171
+ attempts.append(att)
172
+ return {
173
+ "rr_id": str(getattr(rr, "rr_id", "") or ""),
174
+ "file_path": str(getattr(rr, "file_path", "") or ""),
175
+ "symbol": getattr(rr, "symbol", None),
176
+ "category": str(getattr(rr, "category", "") or ""),
177
+ "severity": str(getattr(rr, "severity", "medium") or "medium"),
178
+ "rationale": str(getattr(rr, "rationale", "") or ""),
179
+ "recommendation": str(getattr(rr, "recommendation", "") or ""),
180
+ "operation_type": str(getattr(rr, "operation_type", "content_patch") or "content_patch"),
181
+ "status": status,
182
+ "outcome": outcome,
183
+ "attempt_count": int(getattr(rr, "attempt_count", 0) or 0),
184
+ "detail": None,
185
+ "attempts": attempts,
186
+ "refactor_branch": str(getattr(rr, "refactor_branch", "") or ""),
187
+ "commit_sha": str(getattr(rr, "commit_sha", "") or ""),
188
+ "reverted": bool(getattr(rr, "reverted", False)),
189
+ }
190
+
191
+
133
192
  def _auth_and_project(project_root: Path | str, config: dict | None) -> tuple[object | None, str]:
134
193
  if telemetry_disabled():
135
194
  return None, ""
@@ -190,6 +249,30 @@ def post_heartbeat(
190
249
  return _post(url, auth.key.key, body, timeout)
191
250
 
192
251
 
252
+ def post_refactor_requests(
253
+ project_root: Path | str, *, config: dict | None = None, timeout: float = 8.0
254
+ ) -> bool:
255
+ """Sync the full local RR store to the platform. Best-effort + fail-open.
256
+
257
+ Sends the complete local snapshot (so terminal RRs land as their resolved
258
+ status) and asks the server to reconcile any previously-synced open RRs that
259
+ are no longer present locally (e.g. cleared by a fresh review). Never raises.
260
+ """
261
+ auth, project_id = _auth_and_project(project_root, config)
262
+ if not auth or not project_id:
263
+ return False
264
+ try:
265
+ from refactor_core.refactor_requests import list_requests
266
+
267
+ requests = list_requests(Path(project_root))
268
+ except Exception:
269
+ return False
270
+ items = [build_refactor_request(rr) for rr in requests]
271
+ url = f"{platform_url()}/v1/projects/{project_id}/refactor-requests/ingest"
272
+ body = {"reconcile": True, "requests": items}
273
+ return _post(url, auth.key.key, body, timeout)
274
+
275
+
193
276
  def emit_node(project_root: Path | str, node, *, config: dict | None = None) -> bool:
194
277
  """Build + post a commit event from a watch ``CommitNode``. Never raises."""
195
278
  try:
@@ -9,12 +9,25 @@ without a live terminal, a queue file, or the review engine.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import time
12
13
  from dataclasses import dataclass, field
13
14
  from datetime import datetime, timezone
14
15
 
15
16
  from rich.console import Group
16
17
  from rich.text import Text
17
18
 
19
+ # Braille spinner frames for the "reviewing" (commit-analysis) status line, so a
20
+ # long analysis animates instead of looking hung. Frame is derived from the wall
21
+ # clock so the graph animates whenever it is re-rendered (the TUI drives that via
22
+ # a background refresher during the blocking review).
23
+ _SPINNER_FRAMES = "\u280b\u2819\u2839\u2838\u283c\u2834\u2826\u2827\u2807\u280f"
24
+
25
+
26
+ def _spinner_frame(now: datetime | None = None) -> str:
27
+ """Return the current spinner glyph (time-based; ~12.5 fps)."""
28
+ tick = time.monotonic() if now is None else now.timestamp()
29
+ return _SPINNER_FRAMES[int(tick * 12.5) % len(_SPINNER_FRAMES)]
30
+
18
31
  # Node lifecycle states (docs/41 Part F).
19
32
  QUEUED = "queued"
20
33
  REVIEWING = "reviewing"
@@ -393,7 +406,8 @@ def _status_line(node: CommitNode) -> str:
393
406
  return "[dim]queued[/dim]"
394
407
  if node.state == REVIEWING:
395
408
  scope = f" ({node.changed} files)" if node.changed else ""
396
- return f"[dim]reviewing changed+associated{scope}...[/dim]"
409
+ spinner = _spinner_frame()
410
+ return f"[cyan]{spinner}[/cyan] [dim]analyzing changed+associated{scope}...[/dim]"
397
411
  if node.state == ERROR:
398
412
  return f"[red]review error:[/red] {node.error or 'unknown'}"
399
413
  # reviewed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.5.3
3
+ Version: 0.5.6
4
4
  Summary: Local-first CLI for the refactor platform
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
File without changes
File without changes