refactorai-cli 0.3.13__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/PKG-INFO +2 -2
  2. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/pyproject.toml +2 -2
  3. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/__init__.py +1 -1
  4. refactorai_cli-0.4.0/refactorai_cli/commands/hook_cmds.py +197 -0
  5. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/commands/run_cmds.py +228 -2
  6. refactorai_cli-0.4.0/refactorai_cli/commands/watch_cmds.py +321 -0
  7. refactorai_cli-0.4.0/refactorai_cli/commit_queue.py +138 -0
  8. refactorai_cli-0.4.0/refactorai_cli/git_scope.py +215 -0
  9. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/main.py +11 -0
  10. refactorai_cli-0.4.0/refactorai_cli/review_runner.py +137 -0
  11. refactorai_cli-0.4.0/refactorai_cli/watch_state.py +318 -0
  12. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli.egg-info/PKG-INFO +2 -2
  13. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli.egg-info/SOURCES.txt +7 -1
  14. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli.egg-info/requires.txt +1 -1
  15. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/README.md +0 -0
  16. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/auth.py +0 -0
  17. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/client.py +0 -0
  18. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/cloud_rr.py +0 -0
  19. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/commands/__init__.py +0 -0
  20. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/commands/account_cmds.py +0 -0
  21. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/commands/auth_cmds.py +0 -0
  22. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/commands/cloud_cmds.py +0 -0
  23. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/commands/engine_cmds.py +0 -0
  24. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/commands/model_cmds.py +0 -0
  25. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/commands/rules_cmds.py +0 -0
  26. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/commands/runtime_cmds.py +0 -0
  27. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
  28. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/commands/setup_cmds.py +0 -0
  29. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/commands/toolchains_cmds.py +0 -0
  30. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/control_plane.py +0 -0
  31. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/credentials.py +0 -0
  32. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/local_constitution.py +0 -0
  33. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/local_engine_runtime.py +0 -0
  34. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/local_paths.py +0 -0
  35. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/model_policy.py +0 -0
  36. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/runtime_manager.py +0 -0
  37. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/settings.py +0 -0
  38. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli/setup_flow.py +0 -0
  39. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli.egg-info/dependency_links.txt +0 -0
  40. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli.egg-info/entry_points.txt +0 -0
  41. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/refactorai_cli.egg-info/top_level.txt +0 -0
  42. {refactorai_cli-0.3.13 → refactorai_cli-0.4.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-cli
3
- Version: 0.3.13
3
+ Version: 0.4.0
4
4
  Summary: Local-first CLI for the refactor platform
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -8,7 +8,7 @@ Requires-Dist: typer>=0.12.0
8
8
  Requires-Dist: httpx>=0.27.0
9
9
  Requires-Dist: rich>=13.7.0
10
10
  Requires-Dist: PyYAML>=6.0.1
11
- Requires-Dist: refactorai-core>=3.1.1
11
+ Requires-Dist: refactorai-core>=3.2.0
12
12
 
13
13
  # refactorai-cli
14
14
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "refactorai-cli"
3
- version = "0.3.13"
3
+ version = "0.4.0"
4
4
  description = "Local-first CLI for the refactor platform"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -12,7 +12,7 @@ dependencies = [
12
12
  "httpx>=0.27.0",
13
13
  "rich>=13.7.0",
14
14
  "PyYAML>=6.0.1",
15
- "refactorai-core>=3.1.1",
15
+ "refactorai-core>=3.2.0",
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.3.13"
8
+ __version__ = "0.4.0"
@@ -0,0 +1,197 @@
1
+ """`refactor hook` commands: local commit-trigger install + enqueue (docs/41 `R28`).
2
+
3
+ Phase 2 of the commit-triggered Refactor Terminal. ``install`` wires a
4
+ non-blocking ``post-commit`` git hook (via ``core.hooksPath``) that enqueues each
5
+ commit; ``enqueue`` is what that hook calls; ``status``/``uninstall`` manage it.
6
+
7
+ The hook and these commands live in the thin CLI (not the proprietary runtime)
8
+ so enqueuing is fast, offline, and never requires auth or a project config.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from pathlib import Path
15
+
16
+ import typer
17
+ from rich.console import Console
18
+
19
+ from refactorai_cli import commit_queue, git_scope
20
+
21
+ console = Console()
22
+ app = typer.Typer(
23
+ name="hook",
24
+ help="Manage the local commit-triggered review hook (docs/41).",
25
+ no_args_is_help=True,
26
+ add_completion=False,
27
+ )
28
+
29
+ _HOOK_MARKER = "RefactorAI post-commit hook"
30
+ _DEFAULT_HOOKS_DIR = ".githooks"
31
+
32
+ # Non-blocking + fail-open: capture HEAD, then detach the enqueue so `git commit`
33
+ # returns immediately. Skip-detection (merge/rebase/opt-out) happens in `enqueue`.
34
+ _POST_COMMIT_SCRIPT = """#!/usr/bin/env bash
35
+ # RefactorAI post-commit hook (docs/41 R28, Phase 2).
36
+ # Non-blocking + fail-open: this hook must never delay or fail `git commit`.
37
+ if ! command -v refactor >/dev/null 2>&1; then
38
+ exit 0
39
+ fi
40
+ _refactor_sha="$(git rev-parse HEAD 2>/dev/null)"
41
+ if [ -z "$_refactor_sha" ]; then
42
+ exit 0
43
+ fi
44
+ # Detach so the commit returns instantly; the enqueue does its own skip checks.
45
+ ( refactor hook enqueue --sha "$_refactor_sha" >/dev/null 2>&1 & ) >/dev/null 2>&1 || true
46
+ exit 0
47
+ """
48
+
49
+
50
+ def _require_repo(project_root: Path) -> str:
51
+ root = git_scope.repo_root(project_root)
52
+ if not root:
53
+ console.print("[red]hook:[/red] not inside a git repository.")
54
+ raise typer.Exit(code=1)
55
+ return root
56
+
57
+
58
+ @app.command("install")
59
+ def install(
60
+ hooks_dir: str | None = typer.Option(
61
+ None,
62
+ "--hooks-dir",
63
+ help=f"Directory to hold git hooks (default: existing core.hooksPath or {_DEFAULT_HOOKS_DIR}).",
64
+ ),
65
+ force: bool = typer.Option(
66
+ False,
67
+ "--force",
68
+ help="Overwrite an existing non-RefactorAI post-commit hook.",
69
+ ),
70
+ ) -> None:
71
+ """Install the non-blocking post-commit hook and wire ``core.hooksPath``."""
72
+ root = Path(_require_repo(Path.cwd()))
73
+
74
+ existing_path = git_scope.get_config(root, "core.hooksPath")
75
+ if hooks_dir:
76
+ rel_dir = hooks_dir
77
+ elif existing_path:
78
+ rel_dir = existing_path
79
+ else:
80
+ rel_dir = _DEFAULT_HOOKS_DIR
81
+
82
+ hooks_path = (root / rel_dir).resolve()
83
+ hook_file = hooks_path / "post-commit"
84
+
85
+ if hook_file.exists() and _HOOK_MARKER not in _safe_read(hook_file) and not force:
86
+ console.print(
87
+ f"[yellow]hook:[/yellow] a post-commit hook already exists at {hook_file}.\n"
88
+ "Re-run with [bold]--force[/bold] to overwrite, or point --hooks-dir elsewhere."
89
+ )
90
+ raise typer.Exit(code=1)
91
+
92
+ try:
93
+ hooks_path.mkdir(parents=True, exist_ok=True)
94
+ hook_file.write_text(_POST_COMMIT_SCRIPT, encoding="utf-8")
95
+ hook_file.chmod(hook_file.stat().st_mode | 0o755)
96
+ except OSError as exc:
97
+ console.print(f"[red]hook: could not write hook:[/red] {exc}")
98
+ raise typer.Exit(code=1) from exc
99
+
100
+ if not git_scope.set_config(root, "core.hooksPath", rel_dir):
101
+ console.print("[red]hook: could not set core.hooksPath.[/red]")
102
+ raise typer.Exit(code=1)
103
+
104
+ console.print(
105
+ f"[green]Installed[/green] post-commit hook -> {hook_file}\n"
106
+ f"[dim]core.hooksPath = {rel_dir}[/dim]\n"
107
+ "Every commit now enqueues a local review event. "
108
+ "Open the Refactor Terminal with `refactor watch` (coming in Phase 3)."
109
+ )
110
+
111
+
112
+ @app.command("uninstall")
113
+ def uninstall(
114
+ reset_hooks_path: bool = typer.Option(
115
+ False,
116
+ "--reset-hooks-path",
117
+ help="Also unset core.hooksPath (only do this if RefactorAI set it).",
118
+ ),
119
+ ) -> None:
120
+ """Remove the RefactorAI post-commit hook."""
121
+ root = Path(_require_repo(Path.cwd()))
122
+ rel_dir = git_scope.get_config(root, "core.hooksPath") or _DEFAULT_HOOKS_DIR
123
+ hook_file = (root / rel_dir / "post-commit").resolve()
124
+
125
+ removed = False
126
+ if hook_file.exists() and _HOOK_MARKER in _safe_read(hook_file):
127
+ try:
128
+ hook_file.unlink()
129
+ removed = True
130
+ except OSError as exc:
131
+ console.print(f"[red]hook: could not remove hook:[/red] {exc}")
132
+ raise typer.Exit(code=1) from exc
133
+
134
+ if reset_hooks_path:
135
+ git_scope.unset_config(root, "core.hooksPath")
136
+
137
+ if removed:
138
+ console.print("[green]Removed[/green] the RefactorAI post-commit hook.")
139
+ else:
140
+ console.print("[dim]hook: no RefactorAI post-commit hook found to remove.[/dim]")
141
+
142
+
143
+ @app.command("enqueue")
144
+ def enqueue(
145
+ sha: str = typer.Option("HEAD", "--sha", help="Commit SHA/ref to enqueue."),
146
+ verbose: bool = typer.Option(False, "--verbose", help="Print the enqueue result."),
147
+ ) -> None:
148
+ """Enqueue a commit event (invoked by the post-commit hook). Always exits 0."""
149
+ project_root = Path(os.environ.get("REFACTOR_PROJECT_ROOT") or Path.cwd())
150
+ try:
151
+ enqueued, reason, event = commit_queue.enqueue_commit_event(project_root, sha)
152
+ except Exception as exc: # fail-open: never let enqueue affect the commit flow
153
+ if verbose:
154
+ console.print(f"[yellow]hook enqueue error (ignored):[/yellow] {exc}")
155
+ return
156
+ if verbose:
157
+ if enqueued and event:
158
+ console.print(
159
+ f"[green]enqueued[/green] {event['sha'][:10]} "
160
+ f"({len(event['changed_files'])} changed file(s)) -> "
161
+ f"{commit_queue.queue_path(project_root)}"
162
+ )
163
+ else:
164
+ console.print(f"[dim]hook: not enqueued ({reason}).[/dim]")
165
+
166
+
167
+ @app.command("status")
168
+ def status() -> None:
169
+ """Show hook installation state and the pending commit-event count."""
170
+ root_str = git_scope.repo_root(Path.cwd())
171
+ if not root_str:
172
+ console.print("[yellow]hook:[/yellow] not inside a git repository.")
173
+ raise typer.Exit(code=1)
174
+ root = Path(root_str)
175
+
176
+ rel_dir = git_scope.get_config(root, "core.hooksPath")
177
+ hook_file = (root / (rel_dir or _DEFAULT_HOOKS_DIR) / "post-commit").resolve()
178
+ is_ours = hook_file.exists() and _HOOK_MARKER in _safe_read(hook_file)
179
+ q_path = commit_queue.queue_path(root)
180
+
181
+ console.print(f"Repository: [bold]{root}[/bold]")
182
+ console.print(f"core.hooksPath: {rel_dir or '[dim](unset)[/dim]'}")
183
+ if is_ours:
184
+ console.print(f"post-commit hook: [green]installed[/green] ({hook_file})")
185
+ elif hook_file.exists():
186
+ console.print(f"post-commit hook: [yellow]present but not RefactorAI[/yellow] ({hook_file})")
187
+ else:
188
+ console.print("post-commit hook: [red]not installed[/red] (run `refactor hook install`)")
189
+ console.print(f"Queue: {q_path}")
190
+ console.print(f"Pending events: [bold]{commit_queue.pending_count(root)}[/bold]")
191
+
192
+
193
+ def _safe_read(path: Path) -> str:
194
+ try:
195
+ return path.read_text(encoding="utf-8")
196
+ except OSError:
197
+ return ""
@@ -1800,6 +1800,134 @@ def shell(
1800
1800
  raise typer.Exit(code=1)
1801
1801
 
1802
1802
 
1803
+ def _graph_expand_hops(constitution) -> int:
1804
+ """Graph expansion depth for commit scope (constitution intelligence)."""
1805
+ intel = constitution.get_setting("intelligence", {}) if constitution else {}
1806
+ if isinstance(intel, dict):
1807
+ try:
1808
+ return max(0, int(intel.get("graph_expand_hops", 1) or 1))
1809
+ except (TypeError, ValueError):
1810
+ return 1
1811
+ return 1
1812
+
1813
+
1814
+ def _associated_files_for(project_root: Path, changed: list[str], hops: int) -> list[str]:
1815
+ """Files graph-associated with ``changed`` via the persisted symbol graph.
1816
+
1817
+ Best-effort: returns ``[]`` when no graph is available or the changed files
1818
+ have no matching symbols, so review simply falls back to the changed files.
1819
+ """
1820
+ if not changed:
1821
+ return []
1822
+ try:
1823
+ from refactor_core.codeintel.context import associated_files
1824
+ from refactor_core.codeintel.graph import SymbolGraph
1825
+ from refactor_core.store import read_symbol_graph
1826
+
1827
+ graph_dict = read_symbol_graph(project_root)
1828
+ if not graph_dict:
1829
+ return []
1830
+ graph = SymbolGraph.from_dict(graph_dict)
1831
+ return associated_files(graph, changed, hops=hops)
1832
+ except Exception:
1833
+ return []
1834
+
1835
+
1836
+ def _resolve_commit_scope(
1837
+ *,
1838
+ project_root: Path,
1839
+ constitution,
1840
+ commit: str | None,
1841
+ changed: bool,
1842
+ intent: str | None,
1843
+ ) -> dict | None:
1844
+ """Resolve commit/working-tree scope for a review (docs/41 `R28`, Phase 1).
1845
+
1846
+ Returns ``None`` when no scoping was requested. Otherwise a dict with:
1847
+ ``scope_paths`` (changed + associated, or ``None`` for a full-project
1848
+ baseline), ``changed_paths``, ``intent`` (canonical contract or ``None``),
1849
+ ``summary`` (one-line intent), and ``note`` (human scope description).
1850
+ """
1851
+ if not commit and not changed:
1852
+ return None
1853
+
1854
+ from refactorai_cli import git_scope
1855
+ from refactor_core.intent import capture as intent_capture
1856
+
1857
+ if not git_scope.is_git_repo(project_root):
1858
+ console.print("[yellow]review: --commit/--changed requires a git repository; ignoring scope.[/yellow]")
1859
+ return None
1860
+
1861
+ sources: list[dict] = []
1862
+ changed_files: list[str] = []
1863
+ full_project = False
1864
+ scope_label = ""
1865
+
1866
+ if commit:
1867
+ sha = git_scope.resolve_sha(project_root, commit)
1868
+ if not sha:
1869
+ console.print(f"[red]review: could not resolve commit '{commit}'.[/red]")
1870
+ raise typer.Exit(code=2)
1871
+ subject, message = git_scope.commit_message(project_root, sha)
1872
+ if subject or message:
1873
+ sources.append(
1874
+ {"type": "commit_message", "title": subject, "text": message, "ref": sha}
1875
+ )
1876
+ if git_scope.is_first_commit(project_root, sha):
1877
+ full_project = True
1878
+ scope_label = f"first commit {sha[:10]} -> full project"
1879
+ else:
1880
+ changed_files = git_scope.commit_changed_files(project_root, sha)
1881
+ scope_label = f"commit {sha[:10]}"
1882
+ elif changed:
1883
+ changed_files = git_scope.working_tree_changed_files(project_root)
1884
+ scope_label = "working tree"
1885
+
1886
+ if intent and intent.strip():
1887
+ sources.append({"type": "agent_prompt", "text": intent.strip()})
1888
+
1889
+ canonical = intent_capture.derive_contract(sources) if sources else None
1890
+ summary = intent_capture.summarize_intent(canonical)
1891
+
1892
+ if full_project:
1893
+ return {
1894
+ "scope_paths": None,
1895
+ "changed_paths": None,
1896
+ "intent": canonical,
1897
+ "summary": summary,
1898
+ "note": scope_label,
1899
+ }
1900
+
1901
+ # Restrict to files that still exist so a deleted path never becomes a target.
1902
+ existing_changed = [p for p in changed_files if (project_root / p).is_file()]
1903
+ if not existing_changed:
1904
+ console.print(
1905
+ f"[dim]review: no reviewable changed files in {scope_label}; "
1906
+ "falling back to full target.[/dim]"
1907
+ )
1908
+ return {
1909
+ "scope_paths": None,
1910
+ "changed_paths": None,
1911
+ "intent": canonical,
1912
+ "summary": summary,
1913
+ "note": f"{scope_label} (no changed files)",
1914
+ }
1915
+
1916
+ associated = _associated_files_for(project_root, existing_changed, _graph_expand_hops(constitution))
1917
+ scope_paths: list[str] = []
1918
+ for path in [*existing_changed, *associated]:
1919
+ if path not in scope_paths:
1920
+ scope_paths.append(path)
1921
+
1922
+ return {
1923
+ "scope_paths": scope_paths,
1924
+ "changed_paths": existing_changed,
1925
+ "intent": canonical,
1926
+ "summary": summary,
1927
+ "note": f"{scope_label}: {len(existing_changed)} changed + {len(associated)} associated",
1928
+ }
1929
+
1930
+
1803
1931
  def review(
1804
1932
  target: str = typer.Argument(".", help="File, directory, or symbol to review."),
1805
1933
  provider: str | None = typer.Option(
@@ -1817,6 +1945,25 @@ def review(
1817
1945
  "--force",
1818
1946
  help="Run review even if unapplied patches exist from a prior code run.",
1819
1947
  ),
1948
+ commit: str | None = typer.Option(
1949
+ None,
1950
+ "--commit",
1951
+ help="Scope the review to a commit's changed files + graph-associated "
1952
+ "files (local only, docs/41). Use a SHA/ref; the repo root commit falls "
1953
+ "back to a full-project review.",
1954
+ ),
1955
+ changed: bool = typer.Option(
1956
+ False,
1957
+ "--changed",
1958
+ help="Scope the review to the working tree's changed files + "
1959
+ "graph-associated files (local only).",
1960
+ ),
1961
+ intent: str | None = typer.Option(
1962
+ None,
1963
+ "--intent",
1964
+ help="Explicit intent for this change (agent-supplied). Merged with any "
1965
+ "commit-message-derived intent to guide the review.",
1966
+ ),
1820
1967
  compliance_framework: list[str] | None = typer.Option(
1821
1968
  None,
1822
1969
  "--compliance-framework",
@@ -1878,6 +2025,29 @@ def review(
1878
2025
  console.print(f"[yellow]review: compliance warning:[/yellow] {warning}")
1879
2026
  register_project(project_root, constitution_hash=constitution.content_hash)
1880
2027
  _maybe_build_graph(project_root, project_config)
2028
+
2029
+ # Commit-scoped local review (docs/41 `R28`). Local-only: the cloud path does
2030
+ # not carry an explicit file scope, so scoping + cloud mode is rejected early.
2031
+ if (commit or changed) and _is_cloud_mode(constitution):
2032
+ console.print(
2033
+ "[red]review: --commit/--changed is local-only[/red] and is not "
2034
+ f"supported in {_cloud_execution_mode(constitution)} mode."
2035
+ )
2036
+ raise typer.Exit(code=2)
2037
+ scope = _resolve_commit_scope(
2038
+ project_root=project_root,
2039
+ constitution=constitution,
2040
+ commit=commit,
2041
+ changed=changed,
2042
+ intent=intent,
2043
+ )
2044
+ scope_paths = scope.get("scope_paths") if scope else None
2045
+ changed_paths = scope.get("changed_paths") if scope else None
2046
+ if scope:
2047
+ console.print(f"[cyan]review scope[/cyan]: {scope['note']}")
2048
+ if scope.get("summary"):
2049
+ console.print(f"[dim]intent:[/dim] {scope['summary']}")
2050
+
1881
2051
  sandbox_ctx = None
1882
2052
 
1883
2053
  try:
@@ -1926,7 +2096,11 @@ def review(
1926
2096
  cli_mode=mode,
1927
2097
  progress_cb=progress_cb,
1928
2098
  step_limit_cb=_step_limit_prompt("review"),
2099
+ scope_paths=scope_paths,
2100
+ changed_paths=changed_paths,
1929
2101
  )
2102
+ if scope and scope.get("summary"):
2103
+ artifact.notes.append(f"Commit-scoped review intent: {scope['summary']}")
1930
2104
  preflight_warning = ""
1931
2105
  if isinstance(sandbox_ctx, dict):
1932
2106
  preflight_warning = str(sandbox_ctx.get("preflight_warning") or "").strip()
@@ -2026,6 +2200,24 @@ def code(
2026
2200
  "--mode",
2027
2201
  help="Execution mode: auto | agentic | planner (default resolves from capability).",
2028
2202
  ),
2203
+ commit: str | None = typer.Option(
2204
+ None,
2205
+ "--commit",
2206
+ help="Scope to a commit's changed + graph-associated files and (re)create "
2207
+ "that commit's refactor requests before resolving them (local only, docs/41). "
2208
+ "Used by the Refactor Terminal's [r] action.",
2209
+ ),
2210
+ changed: bool = typer.Option(
2211
+ False,
2212
+ "--changed",
2213
+ help="Scope to the working tree's changed + graph-associated files (local only).",
2214
+ ),
2215
+ intent: str | None = typer.Option(
2216
+ None,
2217
+ "--intent",
2218
+ help="Explicit intent for this change (agent-supplied), merged with any "
2219
+ "commit-message-derived intent to guide the review that creates requests.",
2220
+ ),
2029
2221
  auto_approve: bool = typer.Option(
2030
2222
  False,
2031
2223
  "--auto-approve",
@@ -2106,6 +2298,31 @@ def code(
2106
2298
  console.print(f"[yellow]code: compliance warning:[/yellow] {warning}")
2107
2299
  register_project(project_root, constitution_hash=constitution.content_hash)
2108
2300
  _maybe_build_graph(project_root, project_config)
2301
+
2302
+ # Commit-scoped resolve (docs/41 `R28`, Phase 4). Local-only, symmetric to
2303
+ # `review`. When scoped, we (re)create THIS commit's requests via a scoped
2304
+ # review-first so `[r]` in the Refactor Terminal resolves the selected
2305
+ # commit's requests rather than a stale pending set.
2306
+ if (commit or changed) and _is_cloud_mode(constitution):
2307
+ console.print(
2308
+ "[red]code: --commit/--changed is local-only[/red] and is not "
2309
+ f"supported in {_cloud_execution_mode(constitution)} mode."
2310
+ )
2311
+ raise typer.Exit(code=2)
2312
+ code_scope = _resolve_commit_scope(
2313
+ project_root=project_root,
2314
+ constitution=constitution,
2315
+ commit=commit,
2316
+ changed=changed,
2317
+ intent=intent,
2318
+ )
2319
+ code_scope_paths = code_scope.get("scope_paths") if code_scope else None
2320
+ code_changed_paths = code_scope.get("changed_paths") if code_scope else None
2321
+ if code_scope:
2322
+ console.print(f"[cyan]code scope[/cyan]: {code_scope['note']}")
2323
+ if code_scope.get("summary"):
2324
+ console.print(f"[dim]intent:[/dim] {code_scope['summary']}")
2325
+
2109
2326
  sandbox_ctx = None
2110
2327
 
2111
2328
  try:
@@ -2138,9 +2355,16 @@ def code(
2138
2355
  cloud_review_findings: list[Finding] = []
2139
2356
 
2140
2357
  # If no pending refactor requests exist, run review first to create them.
2141
- requests = pending_requests(project_root)
2358
+ # A commit/working-tree scope always forces a fresh scoped review so the
2359
+ # requests match the selected commit (docs/41 `R28`, Phase 4).
2360
+ requests = [] if code_scope else pending_requests(project_root)
2142
2361
  if not requests:
2143
- console.print("[dim]code: no pending refactor requests; running review first...[/dim]")
2362
+ if code_scope:
2363
+ console.print(
2364
+ f"[dim]code: creating refactor requests for {code_scope['note']}...[/dim]"
2365
+ )
2366
+ else:
2367
+ console.print("[dim]code: no pending refactor requests; running review first...[/dim]")
2144
2368
  if cloud:
2145
2369
  review_artifact = _cloud_inference(
2146
2370
  stage="review",
@@ -2166,6 +2390,8 @@ def code(
2166
2390
  cli_mode=mode,
2167
2391
  progress_cb=review_progress,
2168
2392
  step_limit_cb=_step_limit_prompt("review"),
2393
+ scope_paths=code_scope_paths,
2394
+ changed_paths=code_changed_paths,
2169
2395
  )
2170
2396
  review_store = create_run(project_root, run_id=review_artifact.run_id)
2171
2397
  review_store.write_artifact(review_artifact, target=target, status="completed")