refactorai-cli 0.4.10__tar.gz → 0.4.12__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.10 → refactorai_cli-0.4.12}/PKG-INFO +2 -2
  2. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/pyproject.toml +2 -2
  3. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/__init__.py +1 -1
  4. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/cloud_rr.py +15 -2
  5. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/commands/branch_cmds.py +3 -0
  6. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/commands/run_cmds.py +27 -6
  7. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/commands/watch_cmds.py +340 -36
  8. refactorai_cli-0.4.12/refactorai_cli/refactor_branch_store.py +189 -0
  9. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/watch_state.py +289 -13
  10. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli.egg-info/PKG-INFO +2 -2
  11. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli.egg-info/requires.txt +1 -1
  12. refactorai_cli-0.4.10/refactorai_cli/refactor_branch_store.py +0 -116
  13. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/README.md +0 -0
  14. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/auth.py +0 -0
  15. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/client.py +0 -0
  16. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/commands/__init__.py +0 -0
  17. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/commands/account_cmds.py +0 -0
  18. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/commands/auth_cmds.py +0 -0
  19. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/commands/cloud_cmds.py +0 -0
  20. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/commands/engine_cmds.py +0 -0
  21. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/commands/hook_cmds.py +0 -0
  22. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/commands/model_cmds.py +0 -0
  23. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/commands/request_cmds.py +0 -0
  24. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/commands/rules_cmds.py +0 -0
  25. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/commands/runtime_cmds.py +0 -0
  26. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
  27. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/commands/setup_cmds.py +0 -0
  28. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/commands/toolchains_cmds.py +0 -0
  29. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/commit_queue.py +0 -0
  30. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/control_plane.py +0 -0
  31. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/credentials.py +0 -0
  32. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/dotenv_loader.py +0 -0
  33. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/git_scope.py +0 -0
  34. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/local_constitution.py +0 -0
  35. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/local_engine_runtime.py +0 -0
  36. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/local_paths.py +0 -0
  37. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/main.py +0 -0
  38. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/model_policy.py +0 -0
  39. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/review_runner.py +0 -0
  40. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/runtime_manager.py +0 -0
  41. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/settings.py +0 -0
  42. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli/setup_flow.py +0 -0
  43. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli.egg-info/SOURCES.txt +0 -0
  44. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli.egg-info/dependency_links.txt +0 -0
  45. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli.egg-info/entry_points.txt +0 -0
  46. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/refactorai_cli.egg-info/top_level.txt +0 -0
  47. {refactorai_cli-0.4.10 → refactorai_cli-0.4.12}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.4.10
3
+ Version: 0.4.12
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.5
11
+ Requires-Dist: refactorai-core>=3.2.7
12
12
 
13
13
  # refactorai-cli
14
14
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "refactorai-cli"
3
- version = "0.4.10"
3
+ version = "0.4.12"
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.5",
15
+ "refactorai-core>=3.2.7",
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.10"
8
+ __version__ = "0.4.12"
@@ -651,6 +651,7 @@ def run_cloud_refactor_requests(
651
651
  apply_byok_payload: Callable[[dict, object], None] | None = None,
652
652
  toolchain_consent_cb: Callable[[dict], bool] | None = None,
653
653
  commit_cb: Callable[[object], None] | None = None,
654
+ on_rr_failure: str = "continue",
654
655
  ) -> RunArtifact:
655
656
  """Drive pending RRs through the cloud server-run loop and return a summary.
656
657
 
@@ -665,6 +666,12 @@ def run_cloud_refactor_requests(
665
666
  once per RR that reaches ``APPLIED`` so the CLI can record one git commit per
666
667
  resolved request on an isolated refactor branch. Best-effort; git failures
667
668
  never break the cloud RR loop.
669
+
670
+ ``on_rr_failure`` (``continue`` | ``defer`` | ``stop``) mirrors the local
671
+ executor policy and is forwarded to the server so a single failing RR no
672
+ longer halts the whole run by default (parity with local). The client stays a
673
+ thin executor: it stops only when the server returns a ``failed`` verdict with
674
+ no ``next_rr_id`` (older servers that always stop remain compatible).
668
675
  """
669
676
  project_root = Path(project_root)
670
677
  progress_cb = progress_cb or (lambda e: None)
@@ -741,6 +748,7 @@ def run_cloud_refactor_requests(
741
748
  "constitution_hash": constitution.content_hash,
742
749
  "constitution_body": getattr(constitution, "body", "") or "",
743
750
  "model_hint": resolved_model,
751
+ "on_rr_failure": str(on_rr_failure or "continue"),
744
752
  "rrs": [
745
753
  {
746
754
  "rr_id": rr.rr_id,
@@ -875,12 +883,17 @@ def run_cloud_refactor_requests(
875
883
  attempts_by_id.get(completed_id, 0 if verdict == "deferred" else 1),
876
884
  outcomes, counts, progress_cb, commit_cb=commit_cb,
877
885
  )
878
- if verdict == "failed":
886
+ next_id = str(action.get("next_rr_id", "") or "")
887
+ # With `continue`/`defer` (the default) the server advances after a
888
+ # failure, so the client resolves the remaining requests in one pass
889
+ # instead of stopping at the first failure (parity with local). Only a
890
+ # `stop` policy halts the run (no next RR named); a failed *last* RR
891
+ # under `continue` simply ends the queue, it is not a halt.
892
+ if verdict == "failed" and not next_id and str(on_rr_failure) == "stop":
879
893
  stopped = True
880
894
  stop_reason = str(action.get("message", "") or "refactor request failed")
881
895
  _emit(progress_cb, phase="rr", event="run_stopped", rr_id=completed_id, reason=stop_reason)
882
896
  break
883
- next_id = str(action.get("next_rr_id", "") or "")
884
897
  if not next_id:
885
898
  break
886
899
  current_rr_id = next_id
@@ -128,6 +128,9 @@ def merge_branch(
128
128
  ok, output = git_scope.merge(project_root, target, squash=squash)
129
129
  if not ok:
130
130
  return False, f"merge failed / conflicted: {output}"
131
+ # docs/43 `R30`: remember the terminal outcome so the finalize view can render
132
+ # a DONE (merged) node without re-inspecting git.
133
+ refactor_branch_store.set_outcome(project_root, target, refactor_branch_store.OUTCOME_MERGED)
131
134
  if squash:
132
135
  sha = git_scope.commit(project_root, _squash_message(project_root, target, source))
133
136
  if not sha:
@@ -2955,7 +2955,19 @@ def code(
2955
2955
  changed=changed,
2956
2956
  intent=intent,
2957
2957
  )
2958
- _confirm_full_project_scope(code_scope, command="code", assume_yes=yes_full_project)
2958
+ # Decide reuse-vs-generate up front. The full-project confirmation only guards
2959
+ # *creating* a large request set; when we are resolving an existing pending
2960
+ # queue (the common `[r]` resume case) there is nothing to confirm, so a root
2961
+ # 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)
2963
+ will_generate_requests = bool(refresh_requests) or not reused_requests
2964
+ if will_generate_requests:
2965
+ _confirm_full_project_scope(code_scope, command="code", assume_yes=yes_full_project)
2966
+ elif code_scope and code_scope.get("full_project"):
2967
+ console.print(
2968
+ f"[dim]code: resuming {len(reused_requests)} pending request(s) for this "
2969
+ "commit (full-project review already ran).[/dim]"
2970
+ )
2959
2971
  code_scope_paths = code_scope.get("scope_paths") if code_scope else None
2960
2972
  code_changed_paths = code_scope.get("changed_paths") if code_scope else None
2961
2973
  if code_scope:
@@ -3043,11 +3055,9 @@ def code(
3043
3055
  # Reuse the existing pending requests by default (docs/42 `R29`, Phase 1):
3044
3056
  # `[r]`/`code --commit` resolves the queue already created for this commit
3045
3057
  # rather than regenerating it. `--refresh-requests` opts back into a fresh
3046
- # scoped review so the request set is rebuilt from scratch.
3047
- if code_scope and refresh_requests:
3048
- requests = []
3049
- else:
3050
- requests = pending_requests(project_root)
3058
+ # scoped review so the request set is rebuilt from scratch. Computed above
3059
+ # (``reused_requests``) so the full-project prompt could be skipped on reuse.
3060
+ requests = reused_requests
3051
3061
  if not requests:
3052
3062
  if code_scope and refresh_requests:
3053
3063
  console.print(
@@ -3169,6 +3179,7 @@ def code(
3169
3179
  apply_byok_payload=_apply_byok_payload,
3170
3180
  toolchain_consent_cb=_toolchain_consent_prompt(auto_approve=auto_approve),
3171
3181
  commit_cb=rr_commit_cb,
3182
+ on_rr_failure=on_rr_failure,
3172
3183
  )
3173
3184
  else:
3174
3185
  # Local mode is unchanged: dispatch to the local executor with the
@@ -3217,6 +3228,16 @@ def code(
3217
3228
  final_status = "skipped"
3218
3229
  if _final_test_enabled(project_config):
3219
3230
  final_status, _ = _run_final_test_suite(project_root, project_config)
3231
+ # docs/43 `R30`: persist the verdict so the Refactor Terminal finalize view
3232
+ # can show it without re-running the suite.
3233
+ try:
3234
+ from refactorai_cli import refactor_branch_store
3235
+
3236
+ refactor_branch_store.set_final_test(
3237
+ project_root, refactor_branch_ctx.get("branch_name", ""), final_status
3238
+ )
3239
+ except Exception:
3240
+ pass
3220
3241
  decision = _decide_finalize(
3221
3242
  project_config,
3222
3243
  finalize=finalize,
@@ -25,10 +25,15 @@ 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, watch_state
28
+ from refactorai_cli import commit_queue, git_scope, refactor_branch_store, watch_state
29
29
  from refactorai_cli.auth import AuthError, ensure_authenticated
30
30
  from refactorai_cli.review_runner import ReviewRunner, SubprocessReviewRunner
31
- from refactorai_cli.watch_state import WatchState, render_graph
31
+ from refactorai_cli.watch_state import (
32
+ FinalizeInfo,
33
+ RRCommit,
34
+ WatchState,
35
+ render_graph,
36
+ )
32
37
 
33
38
  console = Console()
34
39
 
@@ -121,6 +126,141 @@ def _pending_for_run(
121
126
  return len(matches), lines
122
127
 
123
128
 
129
+ # --------------------------------------------------------------------------- #
130
+ # Finalize / branch-view resolution (docs/43 `R30`, Phase 1) #
131
+ # --------------------------------------------------------------------------- #
132
+
133
+ def _shas_match(a: str, b: str) -> bool:
134
+ """True if two shas refer to the same commit (tolerant of short/long forms)."""
135
+ a = str(a or "")
136
+ b = str(b or "")
137
+ if not a or not b:
138
+ return False
139
+ return a == b or a.startswith(b) or b.startswith(a)
140
+
141
+
142
+ def _resolve_refactor_branch(
143
+ project_root: Path, node
144
+ ) -> tuple[str, "refactor_branch_store.BranchMeta | None"]:
145
+ """Map a commit node to its refactor branch (docs/43 Part C).
146
+
147
+ Resolution order: (1) a branch-store entry whose ``source_sha`` matches the
148
+ node, (2) the ``refactor/<branch>/<shortsha>`` name template, (3) an RR
149
+ record carrying ``refactor_branch`` for this node's run. Returns
150
+ ``("", None)`` when nothing links the node to a refactor branch.
151
+ """
152
+ # 1) recorded source association (reversible, preferred).
153
+ for meta in refactor_branch_store.list_branches(project_root):
154
+ if meta.source_sha and _shas_match(node.sha, meta.source_sha):
155
+ return meta.branch, meta
156
+ # 2) deterministic name template from the node's branch + sha.
157
+ template = refactor_branch_store.refactor_branch_name(node.branch, node.sha)
158
+ meta = refactor_branch_store.load_branch(project_root, template)
159
+ if meta is not None or git_scope.branch_exists(project_root, template):
160
+ return template, meta
161
+ # 3) RR records created by this node's run.
162
+ branch = _branch_from_run(project_root, getattr(node, "run_id", ""))
163
+ if branch:
164
+ return branch, refactor_branch_store.load_branch(project_root, branch)
165
+ return "", None
166
+
167
+
168
+ def _branch_from_run(project_root: Path, run_id: str) -> str:
169
+ """First ``refactor_branch`` recorded on an RR created by ``run_id``."""
170
+ if not run_id:
171
+ return ""
172
+ try:
173
+ from refactor_core.refactor_requests import list_requests
174
+ except Exception:
175
+ return ""
176
+ try:
177
+ requests = list_requests(project_root)
178
+ except Exception:
179
+ return ""
180
+ for rr in requests:
181
+ if str(getattr(rr, "created_by_run", "") or "") == run_id:
182
+ branch = str(getattr(rr, "refactor_branch", "") or "")
183
+ if branch:
184
+ return branch
185
+ return ""
186
+
187
+
188
+ def _rr_commits_for_branch(
189
+ project_root: Path, branch: str, *, run_id: str = ""
190
+ ) -> list[RRCommit]:
191
+ """Per-request commits recorded on ``branch`` (newest first)."""
192
+ if not branch:
193
+ return []
194
+ try:
195
+ from refactor_core.refactor_requests import list_requests
196
+ except Exception:
197
+ return []
198
+ try:
199
+ requests = list_requests(project_root)
200
+ except Exception:
201
+ return []
202
+ out: list[RRCommit] = []
203
+ for rr in requests:
204
+ rr_branch = str(getattr(rr, "refactor_branch", "") or "")
205
+ commit_sha = str(getattr(rr, "commit_sha", "") or "")
206
+ if not commit_sha:
207
+ continue
208
+ # Prefer an exact branch match; fall back to the node's run when the RR
209
+ # predates branch tagging.
210
+ if rr_branch == branch or (not rr_branch and run_id and str(getattr(rr, "created_by_run", "") or "") == run_id):
211
+ out.append(
212
+ RRCommit(
213
+ rr_id=str(getattr(rr, "rr_id", "") or ""),
214
+ commit_sha=commit_sha,
215
+ location=str(getattr(rr, "location", "") or ""),
216
+ status=str(getattr(rr, "status", "") or ""),
217
+ reverted=bool(getattr(rr, "reverted", False)),
218
+ )
219
+ )
220
+ # ``list_requests`` is oldest-first; newest RR commit renders on top.
221
+ out.reverse()
222
+ return out
223
+
224
+
225
+ def resolve_finalize(project_root: Path, node) -> FinalizeInfo | None:
226
+ """Resolve the finalize/branch-view data for a node (docs/43 Part C/G).
227
+
228
+ Returns ``None`` when the node has no refactor branch to finalize. Fail-soft:
229
+ any store/git hiccup yields ``None`` rather than raising into the terminal.
230
+ """
231
+ try:
232
+ branch, meta = _resolve_refactor_branch(project_root, node)
233
+ except Exception:
234
+ return None
235
+ if not branch:
236
+ return None
237
+ try:
238
+ exists = git_scope.branch_exists(project_root, branch)
239
+ except Exception:
240
+ exists = False
241
+ commits = _rr_commits_for_branch(project_root, branch, run_id=getattr(node, "run_id", ""))
242
+ outcome = meta.outcome if meta else ""
243
+ # Nothing to show and nothing recorded -> treat as "no finalize".
244
+ if not commits and not exists and not outcome:
245
+ return None
246
+ return FinalizeInfo(
247
+ refactor_branch=branch,
248
+ source_branch=(meta.source_branch if meta else "") or node.branch,
249
+ source_sha=(meta.source_sha if meta else "") or node.sha,
250
+ branch_exists=exists,
251
+ outcome=outcome,
252
+ final_test=meta.final_test if meta else "",
253
+ commits=commits,
254
+ )
255
+
256
+
257
+ def refresh_finalize(project_root: Path, state: WatchState) -> None:
258
+ """Recompute finalize data for reviewed nodes so phases stay current."""
259
+ for node in state.nodes_oldest_first():
260
+ if node.state == watch_state.REVIEWED:
261
+ node.finalize = resolve_finalize(project_root, node)
262
+
263
+
124
264
  def _resolve_repo(project_root: Path | None) -> Path:
125
265
  root = git_scope.repo_root(project_root or Path.cwd())
126
266
  if not root:
@@ -184,6 +324,11 @@ def process_pending(
184
324
  (rendering happens via ``on_update``) so it is unit-testable with a fake
185
325
  runner and a temporary queue file.
186
326
  """
327
+ # Track the checked-out branch so the finalize view can mark "(current)".
328
+ try:
329
+ state.current_branch = git_scope.current_branch(project_root) or state.current_branch
330
+ except Exception:
331
+ pass
187
332
  for event in commit_queue.read_events(project_root):
188
333
  sha = str(event.get("sha") or "")
189
334
  if sha and not state.has(sha):
@@ -210,6 +355,11 @@ def process_pending(
210
355
  reviewed += 1
211
356
  if on_update:
212
357
  on_update(state)
358
+ # docs/43 Phase 1: fold current refactor-branch data into reviewed nodes so
359
+ # their phase (review/finalize/done) reflects any branch built by `[r]`.
360
+ refresh_finalize(project_root, state)
361
+ if on_update:
362
+ on_update(state)
213
363
  return reviewed
214
364
 
215
365
 
@@ -258,31 +408,61 @@ def _read_key(timeout: float) -> str:
258
408
  termios.tcsetattr(fd, termios.TCSADRAIN, old)
259
409
 
260
410
 
261
- def build_action_command(action: str, sha: str, *, executable: str = "refactor") -> list[str] | None:
262
- """Build the argv for a terminal action. Pure + testable (docs/42 Phase 4).
263
-
264
- ``code`` reuses pending requests; ``refresh`` regenerates them. Both pass
265
- ``--keep`` so resolving from the terminal only *builds* the refactor branch
266
- (per-request commits + final test) and never pops its own merge/keep/drop
267
- prompt — the terminal's ``[m]`` merge / ``[x]`` drop keys are the single,
268
- explicit place that decision is made. ``merge`` / ``drop`` operate on that
269
- already-built refactor branch (local only, never pushes).
411
+ def build_action_command(
412
+ action: str,
413
+ sha: str,
414
+ *,
415
+ executable: str = "refactor",
416
+ branch: str | None = None,
417
+ rr_id: str | None = None,
418
+ yes_full_project: bool = False,
419
+ ) -> list[str] | None:
420
+ """Build the argv for a terminal action. Pure + testable (docs/42, docs/43).
421
+
422
+ Review phase (docs/41): ``code`` reuses pending requests; ``refresh``
423
+ regenerates them. Both pass ``--keep`` so resolving from the terminal only
424
+ *builds* the refactor branch and never pops its own merge/keep/drop prompt —
425
+ the finalize phase's ``[m]``/``[x]`` are the single, explicit place that
426
+ decision is made.
427
+
428
+ Finalize phase (docs/43): ``merge``/``drop`` target this commit's refactor
429
+ ``branch`` (scoped with ``--branch`` when known so a multi-commit terminal is
430
+ unambiguous); ``revert`` reverses a single ``rr_id``. All are local only.
270
431
  """
271
432
  if action == "code":
272
433
  # Commit-scoped so `[r]` resolves THIS commit's requests (docs/41 Phase 4).
273
- return [executable, "code", "--commit", sha, "--keep"]
434
+ cmd = [executable, "code", "--commit", sha, "--keep"]
435
+ if yes_full_project:
436
+ # Root/full-scope commits would otherwise pop a hidden confirmation in
437
+ # the suspended terminal; auto-confirm so `[r]` never appears to hang.
438
+ cmd.append("--yes-full-project")
439
+ return cmd
274
440
  if action == "refresh":
275
441
  # `[f]` regenerates the requests for this commit before resolving.
276
- return [executable, "code", "--commit", sha, "--refresh-requests", "--keep"]
442
+ cmd = [executable, "code", "--commit", sha, "--refresh-requests", "--keep"]
443
+ if yes_full_project:
444
+ cmd.append("--yes-full-project")
445
+ return cmd
277
446
  if action == "diff":
278
447
  # Shows the pending refactor-request patch diffs (not the git diff).
279
448
  return [executable, "diff"]
280
449
  if action == "merge":
281
450
  # `[m]` merges this commit's refactor branch into your working branch.
282
- return [executable, "branch", "merge"]
451
+ cmd = [executable, "branch", "merge"]
452
+ if branch:
453
+ cmd += ["--branch", branch]
454
+ return cmd
283
455
  if action == "drop":
284
456
  # `[x]` discards this commit's refactor branch and its commits.
285
- return [executable, "branch", "drop"]
457
+ cmd = [executable, "branch", "drop"]
458
+ if branch:
459
+ cmd += ["--branch", branch]
460
+ return cmd
461
+ if action == "revert":
462
+ # `[v]` reverses one resolved request via `git revert` (docs/42).
463
+ if not rr_id:
464
+ return None
465
+ return [executable, "request", "revert", "--id", rr_id]
286
466
  return None
287
467
 
288
468
 
@@ -304,7 +484,7 @@ def _run_json_stream(project_root: Path, state: WatchState, runner: ReviewRunner
304
484
 
305
485
  def emit(st: WatchState) -> None:
306
486
  for node in st.nodes_oldest_first():
307
- signature = f"{node.state}:{node.gate_status}:{node.rr_count}"
487
+ signature = f"{node.state}:{node.phase}:{node.gate_status}:{node.rr_count}"
308
488
  if emitted.get(node.sha) != signature:
309
489
  emitted[node.sha] = signature
310
490
  sys.stdout.write(json.dumps({"type": "node", "node": node.to_contract()}) + "\n")
@@ -317,9 +497,30 @@ def _run_json_stream(project_root: Path, state: WatchState, runner: ReviewRunner
317
497
  time.sleep(_POLL_SECONDS)
318
498
 
319
499
 
320
- def _run_action(live, project_root: Path, action: str, node, state: WatchState) -> None:
500
+ def _sync_current_branch(project_root: Path, state: WatchState) -> None:
501
+ try:
502
+ state.current_branch = git_scope.current_branch(project_root) or state.current_branch
503
+ except Exception:
504
+ pass
505
+
506
+
507
+ def _run_action(
508
+ live,
509
+ project_root: Path,
510
+ action: str,
511
+ node,
512
+ state: WatchState,
513
+ *,
514
+ rr_id: str | None = None,
515
+ ) -> None:
321
516
  """Suspend the live graph, run an interactive action, then refresh the node."""
322
- cmd = build_action_command(action, node.sha)
517
+ branch = node.finalize.refactor_branch if getattr(node, "finalize", None) else None
518
+ # Root/full-scope commits expand `code` to a full-project review; auto-confirm
519
+ # so `[r]`/`[f]` never stall on a hidden prompt in the suspended terminal.
520
+ yes_full_project = getattr(node, "scope_mode", "") == "full"
521
+ cmd = build_action_command(
522
+ action, node.sha, branch=branch, rr_id=rr_id, yes_full_project=yes_full_project
523
+ )
323
524
  if not cmd:
324
525
  return
325
526
  live.stop()
@@ -328,21 +529,134 @@ def _run_action(live, project_root: Path, action: str, node, state: WatchState)
328
529
  _launch_interactive(cmd, project_root)
329
530
  if action == "refresh":
330
531
  # `[f]` explicitly regenerates requests, so re-review this commit on the
331
- # next pass.
532
+ # next pass (also clears any `[b] back` override).
332
533
  state.requeue(node.sha)
333
534
  elif action == "code":
334
535
  # `[r]` is resolve/reuse. Do NOT auto re-review here (that can regenerate
335
536
  # duplicate-looking findings); instead reflect current pending requests
336
- # from the existing run in-place.
537
+ # from the existing run in-place, then fold in the refactor branch so the
538
+ # node auto-transitions to the finalize view (docs/43 FR-02).
539
+ state.forced_review.discard(node.sha)
337
540
  node_after = state.get(node.sha)
338
541
  if node_after and node_after.run_id:
339
542
  pending_count, preview = _pending_for_run(project_root, node_after.run_id)
340
543
  node_after.rr_count = pending_count
341
544
  node_after.rr_preview = preview
545
+ _sync_current_branch(project_root, state)
546
+ refresh_finalize(project_root, state)
547
+ elif action in ("merge", "drop", "revert"):
548
+ # Finalize actions (docs/43). `merge_branch` records the merged outcome
549
+ # itself; a completed drop leaves no branch, so tombstone the dropped
550
+ # outcome here so the node collapses to a DONE (dropped) row.
551
+ if action == "drop" and branch and not git_scope.branch_exists(project_root, branch):
552
+ refactor_branch_store.set_outcome(project_root, branch, refactor_branch_store.OUTCOME_DROPPED)
553
+ _sync_current_branch(project_root, state)
554
+ refresh_finalize(project_root, state)
555
+ console.print("\n[dim]watch: returning to the Refactor Terminal...[/dim]")
556
+ live.start()
557
+
558
+
559
+ def _run_revert_action(live, project_root: Path, node, state: WatchState) -> None:
560
+ """`[v]`: pick one resolved request and reverse it via `refactor request revert`."""
561
+ fin = getattr(node, "finalize", None)
562
+ commits = [c for c in (fin.commits if fin else []) if not c.reverted]
563
+ if not commits:
564
+ console.print("[dim]watch: no revertible requests on this refactor branch.[/dim]")
565
+ return
566
+ live.stop()
567
+ console.print("\n[bold bright_cyan]watch:[/bold bright_cyan] revert a refactor request "
568
+ f"on [cyan]{fin.refactor_branch}[/cyan]\n")
569
+ for idx, commit in enumerate(commits, start=1):
570
+ console.print(
571
+ f" [bold]{idx}[/bold]. {commit.location or '(unknown)'} "
572
+ f"[dim](rr {commit.rr_id[:7]}, {(commit.commit_sha or '')[:8]})[/dim]"
573
+ )
574
+ try:
575
+ raw = typer.prompt("Number to revert (blank to cancel)", default="")
576
+ except (typer.Abort, EOFError, KeyboardInterrupt):
577
+ raw = ""
578
+ choice = str(raw).strip()
579
+ if choice:
580
+ try:
581
+ idx = int(choice)
582
+ except ValueError:
583
+ idx = 0
584
+ if 1 <= idx <= len(commits):
585
+ rr_id = commits[idx - 1].rr_id
586
+ _launch_interactive(
587
+ build_action_command("revert", node.sha, rr_id=rr_id), project_root
588
+ )
589
+ refresh_finalize(project_root, state)
590
+ else:
591
+ console.print("[dim]watch: nothing reverted (out of range).[/dim]")
592
+ else:
593
+ console.print("[dim]watch: revert cancelled.[/dim]")
342
594
  console.print("\n[dim]watch: returning to the Refactor Terminal...[/dim]")
343
595
  live.start()
344
596
 
345
597
 
598
+ def _dispatch_key(live, project_root: Path, key: str, state: WatchState) -> None:
599
+ """Route a hotkey to the right action for the selected node's phase (docs/43).
600
+
601
+ Finalize phase offers ``[m]/[x]/[d]/[v]/[b]`` (review keys are hidden);
602
+ review/none/done offers ``[r]/[f]/[d]`` (and ``[b]`` toggles back to a
603
+ finalize node the user pressed ``[b]`` on).
604
+ """
605
+ node = state.selected()
606
+ if node is None:
607
+ return
608
+
609
+ if state.show_finalize(node):
610
+ # --- finalize phase: merge / drop / diff / revert / back -------------- #
611
+ if key == "m":
612
+ _run_action(live, project_root, "merge", node, state)
613
+ elif key == "x":
614
+ _run_action(live, project_root, "drop", node, state)
615
+ elif key == "d":
616
+ _run_action(live, project_root, "diff", node, state)
617
+ elif key == "v":
618
+ _run_revert_action(live, project_root, node, state)
619
+ elif key == "b":
620
+ # Show the review view for this commit so `[f]` fresh review is possible.
621
+ state.forced_review.add(node.sha)
622
+ elif key in ("r", "f"):
623
+ console.print(
624
+ "[dim]watch: this commit is resolved; press [b] to go back and "
625
+ "re-review, or [m] to merge.[/dim]"
626
+ )
627
+ return
628
+
629
+ # --- review / none / done phase ----------------------------------------- #
630
+ if key == "b" and node.sha in state.forced_review:
631
+ # Toggle back to the finalize view for a node we forced into review.
632
+ state.forced_review.discard(node.sha)
633
+ return
634
+ if key in ("r", "f", "d"):
635
+ can_review = node.phase == watch_state.PHASE_REVIEW or node.sha in state.forced_review
636
+ if key == "d":
637
+ if node.state == watch_state.REVIEWED:
638
+ _run_action(live, project_root, "diff", node, state)
639
+ return
640
+ if not can_review:
641
+ console.print(
642
+ f"[dim]watch: nothing to review on {node.short_sha} "
643
+ f"(phase: {node.phase}).[/dim]"
644
+ )
645
+ return
646
+ if node.state == watch_state.REVIEWED:
647
+ _run_action(live, project_root, "code" if key == "r" else "refresh", node, state)
648
+ else:
649
+ console.print(
650
+ f"[dim]watch: {node.short_sha} is '{node.state}'; "
651
+ "wait for review to finish before acting.[/dim]"
652
+ )
653
+ elif key in ("m", "x", "v"):
654
+ console.print(
655
+ "[dim]watch: finalize actions apply after this commit's requests are "
656
+ "resolved onto a refactor branch (press [r]).[/dim]"
657
+ )
658
+
659
+
346
660
  def _run_tui(project_root: Path, state: WatchState, runner: ReviewRunner) -> None:
347
661
  """Render the live Graphite-style graph and consume the queue serially."""
348
662
  from refactorai_cli.main import _print_agent_intro
@@ -370,26 +684,12 @@ def _run_tui(project_root: Path, state: WatchState, runner: ReviewRunner) -> Non
370
684
  state.move_selection(-1)
371
685
  elif key in ("down", "j"):
372
686
  state.move_selection(+1)
373
- elif key in ("r", "f", "d", "m", "x"):
374
- action = {
375
- "r": "code",
376
- "f": "refresh",
377
- "d": "diff",
378
- "m": "merge",
379
- "x": "drop",
380
- }[key]
381
- node = state.selected()
382
- if node and node.state == watch_state.REVIEWED:
383
- _run_action(live, project_root, action, node, state)
384
- elif node:
385
- console.print(
386
- f"[dim]watch: {node.short_sha} is '{node.state}'; "
387
- "wait for review to finish before acting.[/dim]"
388
- )
389
687
  elif key == "p":
390
688
  console.print(
391
689
  "[dim]watch: push arrives in Phase 5; use `git push` for now.[/dim]"
392
690
  )
691
+ elif key in ("r", "f", "d", "m", "x", "v", "b"):
692
+ _dispatch_key(live, project_root, key, state)
393
693
  refresh(state)
394
694
  finally:
395
695
  live.stop()
@@ -418,6 +718,10 @@ def watch(
418
718
  _ensure_auth_in_window(json_output)
419
719
 
420
720
  state = WatchState(repo_name=project_root.name)
721
+ try:
722
+ state.current_branch = git_scope.current_branch(project_root) or ""
723
+ except Exception:
724
+ state.current_branch = ""
421
725
  for event in _load_initial_events(project_root, since, limit):
422
726
  sha = str(event.get("sha") or "")
423
727
  if sha and not state.has(sha):