refactorai-cli 0.4.12__tar.gz → 0.4.16__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 (47) hide show
  1. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/PKG-INFO +2 -2
  2. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/pyproject.toml +2 -2
  3. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/__init__.py +1 -1
  4. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/commands/hook_cmds.py +40 -2
  5. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/commands/run_cmds.py +74 -5
  6. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/commands/watch_cmds.py +139 -3
  7. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/main.py +1 -1
  8. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/watch_state.py +36 -9
  9. refactorai_cli-0.4.16/refactorai_cli/watch_supervisor.py +234 -0
  10. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli.egg-info/PKG-INFO +2 -2
  11. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli.egg-info/SOURCES.txt +1 -0
  12. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli.egg-info/requires.txt +1 -1
  13. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/README.md +0 -0
  14. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/auth.py +0 -0
  15. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/client.py +0 -0
  16. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/cloud_rr.py +0 -0
  17. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/commands/__init__.py +0 -0
  18. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/commands/account_cmds.py +0 -0
  19. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/commands/auth_cmds.py +0 -0
  20. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/commands/branch_cmds.py +0 -0
  21. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/commands/cloud_cmds.py +0 -0
  22. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/commands/engine_cmds.py +0 -0
  23. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/commands/model_cmds.py +0 -0
  24. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/commands/request_cmds.py +0 -0
  25. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/commands/rules_cmds.py +0 -0
  26. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/commands/runtime_cmds.py +0 -0
  27. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
  28. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/commands/setup_cmds.py +0 -0
  29. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/commands/toolchains_cmds.py +0 -0
  30. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/commit_queue.py +0 -0
  31. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/control_plane.py +0 -0
  32. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/credentials.py +0 -0
  33. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/dotenv_loader.py +0 -0
  34. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/git_scope.py +0 -0
  35. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/local_constitution.py +0 -0
  36. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/local_engine_runtime.py +0 -0
  37. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/local_paths.py +0 -0
  38. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/model_policy.py +0 -0
  39. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/refactor_branch_store.py +0 -0
  40. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/review_runner.py +0 -0
  41. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/runtime_manager.py +0 -0
  42. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/settings.py +0 -0
  43. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli/setup_flow.py +0 -0
  44. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli.egg-info/dependency_links.txt +0 -0
  45. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli.egg-info/entry_points.txt +0 -0
  46. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/refactorai_cli.egg-info/top_level.txt +0 -0
  47. {refactorai_cli-0.4.12 → refactorai_cli-0.4.16}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.4.12
3
+ Version: 0.4.16
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.7
11
+ Requires-Dist: refactorai-core>=3.2.9
12
12
 
13
13
  # refactorai-cli
14
14
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "refactorai-cli"
3
- version = "0.4.12"
3
+ version = "0.4.16"
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.7",
15
+ "refactorai-core>=3.2.9",
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.12"
8
+ __version__ = "0.4.16"
@@ -16,7 +16,7 @@ from pathlib import Path
16
16
  import typer
17
17
  from rich.console import Console
18
18
 
19
- from refactorai_cli import commit_queue, git_scope
19
+ from refactorai_cli import commit_queue, git_scope, watch_supervisor
20
20
 
21
21
  console = Console()
22
22
  app = typer.Typer(
@@ -137,7 +137,9 @@ def install(
137
137
  f"[green]Installed[/green] post-commit hook -> {result['hook_file']}\n"
138
138
  f"[dim]core.hooksPath = {result['rel_dir']}[/dim]\n"
139
139
  "Every commit now enqueues a local review event. "
140
- "Open the Refactor Terminal with `refactor watch`."
140
+ "Open the Refactor Terminal with `refactor watch`.\n"
141
+ "[dim]Tip: set watch.auto_open + watch.terminal (or REFACTOR_WATCH_AUTO_OPEN / "
142
+ "REFACTOR_WATCH_TERMINAL) to open it automatically on each commit.[/dim]"
141
143
  )
142
144
 
143
145
 
@@ -195,6 +197,42 @@ def enqueue(
195
197
  else:
196
198
  console.print(f"[dim]hook: not enqueued ({reason}).[/dim]")
197
199
 
200
+ # Opt-in auto-open: mirror how a dev server opens its own terminal. Best-effort
201
+ # and fail-open so it never affects the commit flow (docs/42 `R29`, Phase 4).
202
+ if enqueued:
203
+ _maybe_auto_open(project_root, verbose)
204
+
205
+
206
+ def _watch_settings(project_root: Path) -> dict:
207
+ """Best-effort project-config settings dict (never raises)."""
208
+ try:
209
+ from refactor_core.constitution import (
210
+ CONFIG_FILENAME,
211
+ find_project_files,
212
+ load_config,
213
+ )
214
+
215
+ consti_path, _ = find_project_files(project_root)
216
+ if consti_path is None:
217
+ return {}
218
+ cfg = load_config(consti_path.parent / CONFIG_FILENAME)
219
+ return dict(getattr(cfg, "settings", {}) or {})
220
+ except Exception:
221
+ return {}
222
+
223
+
224
+ def _maybe_auto_open(project_root: Path, verbose: bool) -> None:
225
+ try:
226
+ config = _watch_settings(project_root)
227
+ if not watch_supervisor.auto_open_enabled(config):
228
+ return
229
+ result = watch_supervisor.ensure_watch_open(project_root, config=config)
230
+ if verbose:
231
+ console.print(f"[dim]hook: watch ensure -> {result.get('action')}.[/dim]")
232
+ except Exception as exc: # fail-open
233
+ if verbose:
234
+ console.print(f"[yellow]hook: auto-open skipped ({exc}).[/yellow]")
235
+
198
236
 
199
237
  @app.command("status")
200
238
  def status() -> None:
@@ -64,11 +64,13 @@ from refactor_core.refactor_requests import (
64
64
  DEFERRED,
65
65
  RequestSuppression,
66
66
  RequestWarning,
67
+ clear_open_requests,
67
68
  clear_requests,
68
69
  create_requests,
69
70
  list_requests,
70
71
  load_request,
71
72
  pending_requests,
73
+ pending_requests_for_paths,
72
74
  save_request,
73
75
  )
74
76
  from refactor_core.refactor_ops import remediation_hint
@@ -651,6 +653,26 @@ def _is_cloud_mode(constitution) -> bool:
651
653
  return bool(_cloud_execution_mode(constitution))
652
654
 
653
655
 
656
+ def _documents_digest(documents: list | None) -> str:
657
+ """Stable digest of the exact documents sent to the platform.
658
+
659
+ Folds each document's path + content so a commit-scoped review keys on the
660
+ code it actually reviews. Without this, two commits share the same
661
+ idempotency key (same account/stage/constitution/target) and the server
662
+ replays the FIRST commit's findings for the second commit -- re-suggesting
663
+ changes that were already applied/merged (docs/41 `R28`).
664
+ """
665
+ hasher = hashlib.sha256()
666
+ for doc in sorted(documents or [], key=lambda d: str((d or {}).get("path", ""))):
667
+ if not isinstance(doc, dict):
668
+ continue
669
+ hasher.update(str(doc.get("path", "")).encode("utf-8"))
670
+ hasher.update(b"\x00")
671
+ hasher.update(str(doc.get("content", "")).encode("utf-8"))
672
+ hasher.update(b"\x1f")
673
+ return hasher.hexdigest()
674
+
675
+
654
676
  def _stable_idempotency_key(
655
677
  *,
656
678
  account_id: str,
@@ -659,15 +681,29 @@ def _stable_idempotency_key(
659
681
  target: str,
660
682
  max_alerts: int,
661
683
  execution_mode: str,
684
+ scope_digest: str = "",
662
685
  ) -> str:
663
686
  """Deterministic idempotency key so retries replay instead of re-charging (C10).
664
687
 
665
- The key MUST change when inputs change (target/constitution/stage/mode) so
666
- distinct runs are not incorrectly deduplicated, and MUST be stable across
688
+ The key MUST change when inputs change (target/constitution/stage/mode/scope)
689
+ so distinct runs are not incorrectly deduplicated, and MUST be stable across
667
690
  retries of the same logical run so managed quota is not double-charged.
691
+
692
+ ``scope_digest`` folds the actual reviewed documents into the key so a
693
+ commit-scoped review of a different commit (or of changed code) produces a
694
+ distinct key and a fresh server run, instead of replaying an earlier commit's
695
+ cached findings.
668
696
  """
669
697
  seed = "|".join(
670
- [account_id, execution_mode, stage, constitution_hash, str(target), str(int(max_alerts))]
698
+ [
699
+ account_id,
700
+ execution_mode,
701
+ stage,
702
+ constitution_hash,
703
+ str(target),
704
+ str(int(max_alerts)),
705
+ scope_digest,
706
+ ]
671
707
  )
672
708
  return hashlib.sha256(seed.encode("utf-8")).hexdigest()
673
709
 
@@ -707,6 +743,10 @@ def _cloud_inference(
707
743
 
708
744
  execution_mode = _cloud_execution_mode(constitution) or "cloud_managed"
709
745
  auth_ctx = ensure_authenticated(project_root)
746
+ # Digest the exact documents so the idempotency key keys on the reviewed
747
+ # code, not just account/stage/target. (The backend independently recomputes
748
+ # a documents hash for its own replay guard, so this need not be sent.)
749
+ documents_hash = _documents_digest(documents)
710
750
  payload = {
711
751
  "stage": stage,
712
752
  "execution_mode": execution_mode,
@@ -728,6 +768,7 @@ def _cloud_inference(
728
768
  target=target,
729
769
  max_alerts=max_alerts,
730
770
  execution_mode=execution_mode,
771
+ scope_digest=documents_hash,
731
772
  )
732
773
  try:
733
774
  response = httpx.post(
@@ -2725,6 +2766,8 @@ def review(
2725
2766
  compliance_canary_percent=compliance_canary_pct,
2726
2767
  compliance_resolution=compliance_resolution,
2727
2768
  artifact=artifact,
2769
+ scope_paths=scope_paths,
2770
+ scoped_replace=True,
2728
2771
  )
2729
2772
  _apply_compliance_gate_to_artifact(artifact)
2730
2773
  _apply_merge_contract_to_artifact(artifact, project_root, project_config)
@@ -2959,7 +3002,20 @@ def code(
2959
3002
  # *creating* a large request set; when we are resolving an existing pending
2960
3003
  # queue (the common `[r]` resume case) there is nothing to confirm, so a root
2961
3004
  # commit no longer re-prompts every run (docs/42 `R29`, Part L).
2962
- reused_requests = [] if (code_scope and refresh_requests) else pending_requests(project_root)
3005
+ #
3006
+ # Reuse is *commit-scoped*: a change-scoped `code --commit`/`[r]` only resumes
3007
+ # pending RRs whose file lives in THIS commit's scope, so a leftover pending RR
3008
+ # from an unrelated commit can never bleed into this commit's resolution
3009
+ # (docs/41 `R28`, Phase 1). A full-project scope (root commit) has no path
3010
+ # restriction, so it resumes the whole pending queue as before.
3011
+ if code_scope and refresh_requests:
3012
+ reused_requests = []
3013
+ elif code_scope and code_scope.get("scope_paths") is not None:
3014
+ reused_requests = pending_requests_for_paths(
3015
+ project_root, code_scope.get("scope_paths")
3016
+ )
3017
+ else:
3018
+ reused_requests = pending_requests(project_root)
2963
3019
  will_generate_requests = bool(refresh_requests) or not reused_requests
2964
3020
  if will_generate_requests:
2965
3021
  _confirm_full_project_scope(code_scope, command="code", assume_yes=yes_full_project)
@@ -3115,6 +3171,8 @@ def code(
3115
3171
  compliance_canary_percent=compliance_canary_pct,
3116
3172
  compliance_resolution=compliance_resolution,
3117
3173
  artifact=review_artifact,
3174
+ scope_paths=code_scope_paths,
3175
+ scoped_replace=True,
3118
3176
  )
3119
3177
  _apply_compliance_gate_to_artifact(review_artifact)
3120
3178
  _apply_merge_contract_to_artifact(review_artifact, project_root, project_config)
@@ -3667,8 +3725,19 @@ def _replace_requests_with_framework_guardrails(
3667
3725
  compliance_canary_percent: int = 20,
3668
3726
  compliance_resolution: CompliancePackResolution | None = None,
3669
3727
  artifact: RunArtifact | None = None,
3728
+ scope_paths: list[str] | None = None,
3729
+ scoped_replace: bool = False,
3670
3730
  ) -> list:
3671
- clear_requests(project_root)
3731
+ # A fresh review supersedes the OPEN requests it re-derives, but must preserve
3732
+ # terminal (applied/denied/failed/...) history so an already-resolved finding is
3733
+ # not re-suggested on a later commit (docs/42 `R29`, Phase 2). A commit-scoped
3734
+ # review clears only its own scope's open queue; a full-project review clears all
3735
+ # open requests. ``scoped_replace`` opts into this history-preserving path so
3736
+ # callers that still want a total reset can pass ``scoped_replace=False``.
3737
+ if scoped_replace:
3738
+ clear_open_requests(project_root, scope_paths)
3739
+ else:
3740
+ clear_requests(project_root)
3672
3741
  requests, suppressions, warnings = create_requests(
3673
3742
  project_root,
3674
3743
  findings,
@@ -25,7 +25,13 @@ from typing import Callable
25
25
  import typer
26
26
  from rich.console import Console
27
27
 
28
- from refactorai_cli import commit_queue, git_scope, refactor_branch_store, watch_state
28
+ from refactorai_cli import (
29
+ commit_queue,
30
+ git_scope,
31
+ refactor_branch_store,
32
+ watch_state,
33
+ watch_supervisor,
34
+ )
29
35
  from refactorai_cli.auth import AuthError, ensure_authenticated
30
36
  from refactorai_cli.review_runner import ReviewRunner, SubprocessReviewRunner
31
37
  from refactorai_cli.watch_state import (
@@ -638,9 +644,38 @@ def _dispatch_key(live, project_root: Path, key: str, state: WatchState) -> None
638
644
  _run_action(live, project_root, "diff", node, state)
639
645
  return
640
646
  if not can_review:
647
+ preferred = next(
648
+ (
649
+ n
650
+ for n in state.nodes_newest_first()
651
+ if n.state == watch_state.REVIEWED
652
+ and (n.phase == watch_state.PHASE_REVIEW or n.sha in state.forced_review)
653
+ and n.rr_count > 0
654
+ ),
655
+ None,
656
+ )
657
+ fallback = preferred or next(
658
+ (
659
+ n
660
+ for n in state.nodes_newest_first()
661
+ if n.state == watch_state.REVIEWED
662
+ and (n.phase == watch_state.PHASE_REVIEW or n.sha in state.forced_review)
663
+ ),
664
+ None,
665
+ )
666
+ if fallback is not None and fallback.sha != node.sha:
667
+ state.selected_sha = fallback.sha
668
+ console.print(
669
+ f"[dim]watch: selected {node.short_sha} ({node.branch or 'detached'} / "
670
+ f"{node.phase}, requests={node.rr_count}) is not reviewable; moved "
671
+ f"selection to {fallback.short_sha} (requests={fallback.rr_count}). "
672
+ f"Press [{key}] again.[/dim]"
673
+ )
674
+ return
641
675
  console.print(
642
- f"[dim]watch: nothing to review on {node.short_sha} "
643
- f"(phase: {node.phase}).[/dim]"
676
+ f"[dim]watch: selected {node.short_sha} ({node.branch or 'detached'} / "
677
+ f"phase={node.phase}, requests={node.rr_count}) has nothing to "
678
+ "review. Use [↑/↓] to select a commit with open requests.[/dim]"
644
679
  )
645
680
  return
646
681
  if node.state == watch_state.REVIEWED:
@@ -695,7 +730,17 @@ def _run_tui(project_root: Path, state: WatchState, runner: ReviewRunner) -> Non
695
730
  live.stop()
696
731
 
697
732
 
733
+ watch_app = typer.Typer(
734
+ name="watch",
735
+ help="Open the persistent Refactor Terminal (docs/41). Requires a valid key.",
736
+ invoke_without_command=True,
737
+ add_completion=False,
738
+ )
739
+
740
+
741
+ @watch_app.callback(invoke_without_command=True)
698
742
  def watch(
743
+ ctx: typer.Context,
699
744
  json_output: bool = typer.Option(
700
745
  False,
701
746
  "--json",
@@ -714,6 +759,10 @@ def watch(
714
759
  ),
715
760
  ) -> None:
716
761
  """Open the persistent Refactor Terminal (docs/41). Requires a valid key."""
762
+ # A subcommand (e.g. `watch ensure`) was invoked -> let it run instead.
763
+ if ctx.invoked_subcommand is not None:
764
+ return
765
+
717
766
  project_root = _resolve_repo(Path.cwd())
718
767
  _ensure_auth_in_window(json_output)
719
768
 
@@ -743,8 +792,95 @@ def watch(
743
792
  console.print(f"[dim]watch: reviewed {reviewed} commit(s).[/dim]")
744
793
  return
745
794
 
795
+ _open_watch_window(project_root, state, runner)
796
+
797
+
798
+ def _open_watch_window(
799
+ project_root: Path, state: WatchState, runner: ReviewRunner
800
+ ) -> None:
801
+ """Run the interactive TUI while holding the single-instance lock.
802
+
803
+ The lock lets ``watch ensure`` (auto-open) detect an already-open window for
804
+ this repo and never spawn a duplicate (docs/42 Phase 4).
805
+ """
806
+ my_pid = os.getpid()
807
+ watch_supervisor.write_lock(project_root, my_pid)
746
808
  try:
747
809
  _run_tui(project_root, state, runner)
748
810
  except KeyboardInterrupt:
749
811
  pass
812
+ finally:
813
+ watch_supervisor.release_lock(project_root, pid=my_pid)
750
814
  console.print("[dim]watch: closed (the commit hook/queue are unaffected).[/dim]")
815
+
816
+
817
+ def _load_watch_config(project_root: Path) -> dict:
818
+ """Best-effort project-config settings dict (never raises)."""
819
+ try:
820
+ from refactor_core.constitution import (
821
+ CONFIG_FILENAME,
822
+ find_project_files,
823
+ load_config,
824
+ )
825
+
826
+ consti_path, _ = find_project_files(project_root)
827
+ if consti_path is None:
828
+ return {}
829
+ cfg = load_config(consti_path.parent / CONFIG_FILENAME)
830
+ return dict(getattr(cfg, "settings", {}) or {})
831
+ except Exception:
832
+ return {}
833
+
834
+
835
+ @watch_app.command("ensure")
836
+ def ensure(
837
+ force: bool = typer.Option(
838
+ False, "--force", help="Open even if auto-open is disabled in config/env."
839
+ ),
840
+ foreground: bool = typer.Option(
841
+ False,
842
+ "--foreground",
843
+ "-f",
844
+ help="Run the Refactor Terminal inline instead of spawning a new window.",
845
+ ),
846
+ ) -> None:
847
+ """Open the Refactor Terminal if enabled and not already running (auto-open).
848
+
849
+ Invoked by the ``post-commit`` hook when auto-open is on, mirroring how a dev
850
+ server opens its own terminal. Fail-open and idempotent: a single-instance lock
851
+ prevents duplicate windows.
852
+ """
853
+ project_root = _resolve_repo(Path.cwd())
854
+
855
+ if foreground:
856
+ if watch_supervisor.is_watch_running(project_root) and not force:
857
+ console.print("[dim]watch: a Refactor Terminal is already open.[/dim]")
858
+ return
859
+ _ensure_auth_in_window(False)
860
+ state = WatchState(repo_name=project_root.name)
861
+ try:
862
+ state.current_branch = git_scope.current_branch(project_root) or ""
863
+ except Exception:
864
+ state.current_branch = ""
865
+ for event in _load_initial_events(project_root, None, 20):
866
+ sha = str(event.get("sha") or "")
867
+ if sha and not state.has(sha):
868
+ state.upsert_from_event(event)
869
+ runner: ReviewRunner = SubprocessReviewRunner(project_root)
870
+ _open_watch_window(project_root, state, runner)
871
+ return
872
+
873
+ config = _load_watch_config(project_root)
874
+ result = watch_supervisor.ensure_watch_open(
875
+ project_root, config=config, force=force
876
+ )
877
+ action = result.get("action")
878
+ message = result.get("message", "")
879
+ if action == "launched":
880
+ console.print(f"[green]watch:[/green] {message}")
881
+ elif action in ("already_running", "disabled"):
882
+ console.print(f"[dim]watch: {message}.[/dim]")
883
+ elif action == "no_launcher":
884
+ console.print(f"[yellow]watch:[/yellow] {message}.")
885
+ else: # launch_failed / unknown
886
+ console.print(f"[yellow]watch: {message}.[/yellow]")
@@ -156,7 +156,7 @@ app.add_typer(toolchains_cmds.app, name="toolchains")
156
156
  app.add_typer(hook_cmds.app, name="hook")
157
157
 
158
158
  # Persistent Refactor Terminal (R28 Phase 3, docs/41).
159
- app.command()(watch_cmds.watch)
159
+ app.add_typer(watch_cmds.watch_app, name="watch")
160
160
 
161
161
  # Local refactor branch + per-request commits (R29 Phase 3, docs/42).
162
162
  app.add_typer(branch_cmds.app, name="branch")
@@ -447,8 +447,9 @@ def _render_review_view(state: WatchState, *, now: datetime | None = None, width
447
447
  age = humanize_age(node.enqueued_at, now=now)
448
448
  gate = _gate_markup(node)
449
449
  selected = node.sha == state.selected_sha
450
+ node_lines: list[Text] = []
450
451
  # Row 1: node marker + branch + age (+ gate, right aligned). The selected
451
- # node gets a filled marker and bold branch so `[r]`/`[d]` targeting is clear.
452
+ # node gets a filled marker + a visual box so `[r]` targets are explicit.
452
453
  if selected:
453
454
  left = Text.from_markup(
454
455
  f"[bold bright_cyan]●[/bold bright_cyan] [bold bright_cyan]{node.branch or '(detached)'}[/bold bright_cyan]"
@@ -461,25 +462,25 @@ def _render_review_view(state: WatchState, *, now: datetime | None = None, width
461
462
  pad = max(1, inner_width - len(left.plain) - len(gate_text.plain))
462
463
  left.append(" " * pad)
463
464
  left.append_text(gate_text)
464
- lines.append(_with_pad(left))
465
+ node_lines.append(left)
465
466
  # Row 2: pipe + sha + subject.
466
467
  row2 = Text.from_markup(f"[cyan]|[/cyan] [yellow]{node.short_sha}[/yellow] ")
467
468
  row2.append(node.subject or "(no subject)")
468
- lines.append(_with_pad(row2))
469
+ node_lines.append(row2)
469
470
  # Row 3: pipe + status.
470
471
  row3 = Text.from_markup("[cyan]|[/cyan] ")
471
472
  row3.append_text(Text.from_markup(_status_line(node)))
472
- lines.append(_with_pad(row3))
473
+ node_lines.append(row3)
473
474
  if node.state == REVIEWED and node.rr_preview:
474
475
  for item in node.rr_preview:
475
476
  req = Text.from_markup("[cyan]|[/cyan] ")
476
477
  req.append(f"- {item}", style="magenta")
477
- lines.append(_with_pad(req))
478
+ node_lines.append(req)
478
479
  # Row 4: intent summary (only once reviewed and present).
479
480
  if node.state in (REVIEWED,) and node.intent_summary:
480
481
  intent = Text.from_markup("[cyan]|[/cyan] ")
481
482
  intent.append_text(Text.from_markup(f"[dim]intent: {node.intent_summary}[/dim]"))
482
- lines.append(_with_pad(intent))
483
+ node_lines.append(intent)
483
484
  # Once a commit is resolved onto a live refactor branch, hint the finalize
484
485
  # view rather than the (now empty) review actions (docs/43 Part D).
485
486
  if node.phase == PHASE_FINALIZE and node.finalize is not None:
@@ -489,7 +490,7 @@ def _render_review_view(state: WatchState, *, now: datetime | None = None, width
489
490
  "-> select + press [m] to finalize",
490
491
  style="green",
491
492
  )
492
- lines.append(_with_pad(hint))
493
+ node_lines.append(hint)
493
494
  elif node.phase == PHASE_DONE and node.finalize is not None:
494
495
  done = Text.from_markup("[cyan]|[/cyan] ")
495
496
  if node.finalize.outcome == "merged":
@@ -500,10 +501,36 @@ def _render_review_view(state: WatchState, *, now: datetime | None = None, width
500
501
  done.append("refactor branch dropped", style="dim")
501
502
  else:
502
503
  done.append("refactor branch finalized", style="dim")
503
- lines.append(_with_pad(done))
504
- lines.append(_with_pad(Text.from_markup("[cyan]|[/cyan]")))
504
+ node_lines.append(done)
505
+ node_lines.append(Text.from_markup("[cyan]|[/cyan]"))
506
+
507
+ if selected and node_lines:
508
+ # Strong visual target: render the selected commit in a distinct box so
509
+ # `[r]`/`[f]` actions are unambiguous in long commit stacks.
510
+ for idx, row in enumerate(node_lines):
511
+ if idx == 0:
512
+ glyph = "┏ "
513
+ elif idx == len(node_lines) - 1:
514
+ glyph = "┗ "
515
+ else:
516
+ glyph = "┃ "
517
+ wrapped = Text(glyph, style="bold bright_cyan")
518
+ wrapped.append_text(row)
519
+ lines.append(_with_pad(wrapped))
520
+ else:
521
+ for row in node_lines:
522
+ lines.append(_with_pad(row))
505
523
  lines.append(_with_pad(Text.from_markup("[cyan]+[/cyan]")))
506
524
  lines.append(_with_pad(Text("")))
525
+ selected = state.selected()
526
+ if selected is not None:
527
+ context = Text.from_markup("[bold bright_cyan]selected[/bold bright_cyan]: ")
528
+ context.append(selected.short_sha, style="yellow")
529
+ context.append(f" {selected.subject or '(no subject)'}", style="dim")
530
+ context.append(f" phase={selected.phase}", style="dim")
531
+ context.append(f" requests={selected.rr_count}", style="dim")
532
+ lines.append(_with_pad(context))
533
+ lines.append(_with_pad(Text("")))
507
534
  lines.append(
508
535
  _with_pad(
509
536
  Text.from_markup(
@@ -0,0 +1,234 @@
1
+ """Auto-open supervisor for the Refactor Terminal (docs/42 `R29`, Phase 4).
2
+
3
+ Opt-in: after a commit is enqueued, the ``post-commit`` hook can call
4
+ ``refactor watch ensure`` to open the Refactor Terminal in a new terminal window
5
+ when one is not already running -- so review results appear automatically (like a
6
+ Metro / dev-server window) instead of the developer having to remember to run
7
+ ``refactor watch``.
8
+
9
+ Everything here is best-effort and fail-open: it must never delay or fail a commit.
10
+ Auto-open is disabled unless the developer opts in, and opening a *new* window
11
+ needs a configured terminal launcher (there is no reliable cross-platform default),
12
+ so both are surfaced as an env var and a matching ``watch`` config key.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import shlex
20
+ import subprocess
21
+ import sys
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+
25
+ from refactorai_cli import commit_queue
26
+
27
+ LOCK_FILENAME = "watch.lock"
28
+
29
+ # Env knobs (project-config keys under a ``watch`` table mirror these names).
30
+ ENV_AUTO_OPEN = "REFACTOR_WATCH_AUTO_OPEN" # "1"/"true" to enable auto-open
31
+ ENV_TERMINAL = "REFACTOR_WATCH_TERMINAL" # launcher template, uses ``{cmd}``
32
+
33
+ _TRUE_TOKENS = {"1", "true", "yes", "on"}
34
+ # A freshly-launched window that has not yet written its own pid still counts as
35
+ # "running" for this many seconds, so two commits in quick succession do not each
36
+ # spawn a window before the first has come up.
37
+ _LAUNCH_GRACE_SECONDS = 45
38
+
39
+
40
+ def _now_iso() -> str:
41
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
42
+
43
+
44
+ def _as_bool(value: object) -> bool:
45
+ return str(value).strip().lower() in _TRUE_TOKENS
46
+
47
+
48
+ def lock_path(project_root: Path | str) -> Path:
49
+ """Per-repo lock file. Lives beside the commit queue (never tracked)."""
50
+ directory = commit_queue.runs_dir(project_root)
51
+ if directory is None:
52
+ # Outside a git dir (or tests): fall back to the central project store.
53
+ from refactorai_cli.local_paths import project_store_dir
54
+
55
+ directory = project_store_dir(project_root)
56
+ return Path(directory) / LOCK_FILENAME
57
+
58
+
59
+ def _pid_alive(pid: int) -> bool:
60
+ if pid <= 0:
61
+ return False
62
+ try:
63
+ os.kill(pid, 0)
64
+ except ProcessLookupError:
65
+ return False
66
+ except PermissionError:
67
+ return True
68
+ except OSError:
69
+ return False
70
+ return True
71
+
72
+
73
+ def read_lock(project_root: Path | str) -> dict | None:
74
+ path = lock_path(project_root)
75
+ if not path.is_file():
76
+ return None
77
+ try:
78
+ data = json.loads(path.read_text(encoding="utf-8"))
79
+ except (json.JSONDecodeError, OSError):
80
+ return None
81
+ return data if isinstance(data, dict) else None
82
+
83
+
84
+ def write_lock(project_root: Path | str, pid: int, *, launching: bool = False) -> None:
85
+ path = lock_path(project_root)
86
+ try:
87
+ path.parent.mkdir(parents=True, exist_ok=True)
88
+ path.write_text(
89
+ json.dumps({"pid": int(pid), "launching": bool(launching), "at": _now_iso()}),
90
+ encoding="utf-8",
91
+ )
92
+ except OSError:
93
+ pass
94
+
95
+
96
+ def release_lock(project_root: Path | str, *, pid: int | None = None) -> None:
97
+ """Remove the lock. If ``pid`` is given, only remove our own lock."""
98
+ data = read_lock(project_root)
99
+ if data is None:
100
+ return
101
+ if pid is not None and int(data.get("pid", 0) or 0) != int(pid):
102
+ return
103
+ try:
104
+ lock_path(project_root).unlink()
105
+ except OSError:
106
+ pass
107
+
108
+
109
+ def _within_grace(data: dict) -> bool:
110
+ if not data.get("launching"):
111
+ return False
112
+ stamp = str(data.get("at", "") or "")
113
+ if not stamp:
114
+ return False
115
+ try:
116
+ started = datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
117
+ except ValueError:
118
+ return False
119
+ return (datetime.now(timezone.utc) - started).total_seconds() < _LAUNCH_GRACE_SECONDS
120
+
121
+
122
+ def is_watch_running(project_root: Path | str) -> bool:
123
+ """True if a Refactor Terminal is (believed to be) open for this repo."""
124
+ data = read_lock(project_root)
125
+ if not data:
126
+ return False
127
+ if _pid_alive(int(data.get("pid", 0) or 0)):
128
+ return True
129
+ # A just-launched window may not have replaced the launch lock with its own pid
130
+ # yet; treat the launch marker as live for a short grace window.
131
+ return _within_grace(data)
132
+
133
+
134
+ def _watch_config(config: dict | None) -> dict:
135
+ if not isinstance(config, dict):
136
+ return {}
137
+ watch = config.get("watch")
138
+ return watch if isinstance(watch, dict) else {}
139
+
140
+
141
+ def auto_open_enabled(config: dict | None = None, env: dict | None = None) -> bool:
142
+ """Whether auto-open is enabled (env wins over config; default off)."""
143
+ env = os.environ if env is None else env
144
+ raw = env.get(ENV_AUTO_OPEN)
145
+ if raw is not None and str(raw).strip() != "":
146
+ return _as_bool(raw)
147
+ return bool(_watch_config(config).get("auto_open", False))
148
+
149
+
150
+ def resolve_launcher(config: dict | None = None, env: dict | None = None) -> str | None:
151
+ """Terminal launcher template, or ``None`` if unconfigured.
152
+
153
+ The template opens a new terminal window running a command. ``{cmd}`` is
154
+ substituted with the (shell-quoted) ``refactor watch`` invocation; if absent the
155
+ command is appended. Examples::
156
+
157
+ gnome-terminal -- bash -lc {cmd}
158
+ xterm -e {cmd}
159
+ wt.exe {cmd}
160
+ """
161
+ env = os.environ if env is None else env
162
+ raw = env.get(ENV_TERMINAL)
163
+ if raw and str(raw).strip():
164
+ return str(raw).strip()
165
+ template = _watch_config(config).get("terminal")
166
+ return str(template).strip() if template and str(template).strip() else None
167
+
168
+
169
+ def watch_command(executable: str = "refactor") -> list[str]:
170
+ """The inner command a launched window should run."""
171
+ return [executable, "watch"]
172
+
173
+
174
+ def build_launch_command(template: str, inner_cmd: list[str]) -> list[str]:
175
+ """Render a launcher ``template`` + inner command into an argv list."""
176
+ inner = " ".join(shlex.quote(part) for part in inner_cmd)
177
+ rendered = template.replace("{cmd}", inner) if "{cmd}" in template else f"{template} {inner}"
178
+ return shlex.split(rendered)
179
+
180
+
181
+ def spawn_detached(cmd: list[str], cwd: Path | str) -> int | None:
182
+ """Launch ``cmd`` fully detached from the current process. Returns its pid."""
183
+ try:
184
+ proc = subprocess.Popen(
185
+ cmd,
186
+ cwd=str(cwd),
187
+ stdin=subprocess.DEVNULL,
188
+ stdout=subprocess.DEVNULL,
189
+ stderr=subprocess.DEVNULL,
190
+ start_new_session=True,
191
+ )
192
+ except (OSError, ValueError):
193
+ return None
194
+ return proc.pid
195
+
196
+
197
+ def ensure_watch_open(
198
+ project_root: Path | str,
199
+ *,
200
+ config: dict | None = None,
201
+ env: dict | None = None,
202
+ executable: str = "refactor",
203
+ force: bool = False,
204
+ ) -> dict:
205
+ """Open a Refactor Terminal if auto-open is enabled and none is running.
206
+
207
+ Pure-ish orchestration returning a result dict with ``action`` in
208
+ ``{disabled, already_running, no_launcher, launched, launch_failed}`` plus a
209
+ human ``message``. ``force`` bypasses the auto-open gate (used by an explicit
210
+ ``watch ensure --force``).
211
+ """
212
+ if not force and not auto_open_enabled(config, env):
213
+ return {"action": "disabled", "message": "auto-open is disabled"}
214
+ if is_watch_running(project_root):
215
+ return {"action": "already_running", "message": "Refactor Terminal already open"}
216
+
217
+ launcher = resolve_launcher(config, env)
218
+ if not launcher:
219
+ return {
220
+ "action": "no_launcher",
221
+ "message": (
222
+ f"set {ENV_TERMINAL} (or watch.terminal) to a terminal launcher "
223
+ "template to auto-open the Refactor Terminal"
224
+ ),
225
+ }
226
+
227
+ cmd = build_launch_command(launcher, watch_command(executable))
228
+ pid = spawn_detached(cmd, project_root)
229
+ if pid is None:
230
+ return {"action": "launch_failed", "message": f"could not launch: {' '.join(cmd)}"}
231
+ # Record a launch marker so a rapid second commit does not double-open before the
232
+ # window comes up and writes its own pid.
233
+ write_lock(project_root, pid, launching=True)
234
+ return {"action": "launched", "message": f"opened Refactor Terminal (pid {pid})", "pid": pid}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.4.12
3
+ Version: 0.4.16
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.7
11
+ Requires-Dist: refactorai-core>=3.2.9
12
12
 
13
13
  # refactorai-cli
14
14
 
@@ -20,6 +20,7 @@ refactorai_cli/runtime_manager.py
20
20
  refactorai_cli/settings.py
21
21
  refactorai_cli/setup_flow.py
22
22
  refactorai_cli/watch_state.py
23
+ refactorai_cli/watch_supervisor.py
23
24
  refactorai_cli.egg-info/PKG-INFO
24
25
  refactorai_cli.egg-info/SOURCES.txt
25
26
  refactorai_cli.egg-info/dependency_links.txt
@@ -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.7
5
+ refactorai-core>=3.2.9