refactorai-cli 0.1.0__py3-none-any.whl
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.
- refactor_cli/__init__.py +8 -0
- refactor_cli/auth.py +120 -0
- refactor_cli/client.py +46 -0
- refactor_cli/commands/__init__.py +1 -0
- refactor_cli/commands/auth_cmds.py +85 -0
- refactor_cli/commands/engine_cmds.py +147 -0
- refactor_cli/commands/model_cmds.py +121 -0
- refactor_cli/commands/rules_cmds.py +131 -0
- refactor_cli/commands/run_cmds.py +2159 -0
- refactor_cli/commands/runtime_cmds.py +164 -0
- refactor_cli/commands/setup_cmds.py +69 -0
- refactor_cli/control_plane.py +240 -0
- refactor_cli/credentials.py +71 -0
- refactor_cli/main.py +68 -0
- refactor_cli/model_policy.py +171 -0
- refactor_cli/runtime_manager.py +241 -0
- refactor_cli/settings.py +33 -0
- refactor_cli/setup_flow.py +412 -0
- refactorai_cli-0.1.0.dist-info/METADATA +55 -0
- refactorai_cli-0.1.0.dist-info/RECORD +23 -0
- refactorai_cli-0.1.0.dist-info/WHEEL +5 -0
- refactorai_cli-0.1.0.dist-info/entry_points.txt +2 -0
- refactorai_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2159 @@
|
|
|
1
|
+
"""Project/run commands.
|
|
2
|
+
|
|
3
|
+
R7.2 adds central-store registration (`init`, `status`) and R7.3 adds the
|
|
4
|
+
read-only review pipeline (`review`, `diff`). `code`/`apply` handle patch
|
|
5
|
+
generation and application.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shlex
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import uuid
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Callable
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
import typer
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
from rich.table import Table
|
|
24
|
+
|
|
25
|
+
from refactor_core.constitution import (
|
|
26
|
+
CONFIG_FILENAME,
|
|
27
|
+
CONSTITUTION_FILENAME,
|
|
28
|
+
DEFAULT_CONFIG,
|
|
29
|
+
DEFAULT_CONSTITUTION,
|
|
30
|
+
find_project_files,
|
|
31
|
+
has_raw_secret_literal,
|
|
32
|
+
load_config,
|
|
33
|
+
load_constitution,
|
|
34
|
+
raw_secret_matches,
|
|
35
|
+
raw_secret_matches_config,
|
|
36
|
+
)
|
|
37
|
+
from refactor_core.apply import (
|
|
38
|
+
ApplyError,
|
|
39
|
+
apply_changes,
|
|
40
|
+
compute_file_changes,
|
|
41
|
+
read_marker,
|
|
42
|
+
revert_from_backup,
|
|
43
|
+
)
|
|
44
|
+
from refactor_core.budgeting import compute_budget
|
|
45
|
+
from refactor_core.capabilities import resolve_capabilities
|
|
46
|
+
from refactor_core.execution import resolve_mode, run_with_mode
|
|
47
|
+
from refactor_core.execution.rr_executor import run_refactor_requests
|
|
48
|
+
from refactor_core.gate import evaluate_gate
|
|
49
|
+
from refactor_core.refactor_requests import (
|
|
50
|
+
clear_requests,
|
|
51
|
+
create_requests,
|
|
52
|
+
list_requests,
|
|
53
|
+
pending_requests,
|
|
54
|
+
)
|
|
55
|
+
from refactor_core.indexing import index_target
|
|
56
|
+
from refactor_core.models import Finding, GeneratedTest, PatchGroup, RunArtifact
|
|
57
|
+
from refactor_core.pipeline import build_refactor_prompt, build_review_prompt
|
|
58
|
+
from refactor_core.providers import get_provider, resolve_provider_name, supported_providers
|
|
59
|
+
from refactor_core.rules import annotate_documents_with_rules, filter_documents_by_rules
|
|
60
|
+
from refactor_core.sandbox_runtime import (
|
|
61
|
+
DEFAULT_IMAGE as SANDBOX_DEFAULT_IMAGE,
|
|
62
|
+
DEFAULT_SHELL as SANDBOX_DEFAULT_SHELL,
|
|
63
|
+
attach_shell as attach_sandbox_shell,
|
|
64
|
+
command_exists_in_sandbox,
|
|
65
|
+
detect_installer_in_sandbox,
|
|
66
|
+
ensure_sandbox,
|
|
67
|
+
exec_in_sandbox,
|
|
68
|
+
remove_sandbox_container,
|
|
69
|
+
sandbox_name,
|
|
70
|
+
resolve_runtime,
|
|
71
|
+
stop_sandbox,
|
|
72
|
+
)
|
|
73
|
+
from refactor_core.store import (
|
|
74
|
+
append_sandbox_install_history,
|
|
75
|
+
clear_sandbox_state,
|
|
76
|
+
create_run,
|
|
77
|
+
head_run_dir,
|
|
78
|
+
list_runs,
|
|
79
|
+
project_store_dir,
|
|
80
|
+
prune_runs,
|
|
81
|
+
read_findings,
|
|
82
|
+
read_report,
|
|
83
|
+
read_sandbox_install_history,
|
|
84
|
+
read_sandbox_preflight,
|
|
85
|
+
read_sandbox_state,
|
|
86
|
+
register_project,
|
|
87
|
+
write_sandbox_preflight,
|
|
88
|
+
)
|
|
89
|
+
from refactor_core.testcmd import resolve_test_command
|
|
90
|
+
|
|
91
|
+
from refactor_cli.auth import AuthError, ensure_authenticated
|
|
92
|
+
from refactor_cli.settings import platform_url
|
|
93
|
+
|
|
94
|
+
console = Console()
|
|
95
|
+
|
|
96
|
+
_SEVERITY_STYLE = {
|
|
97
|
+
"critical": "bold red",
|
|
98
|
+
"high": "red",
|
|
99
|
+
"medium": "yellow",
|
|
100
|
+
"low": "cyan",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
_REQUIRED_ENV_BY_PROVIDER: dict[str, list[object]] = {
|
|
104
|
+
"openai": ["OPENAI_API_KEY"],
|
|
105
|
+
"openrouter": ["OPENROUTER_API_KEY"],
|
|
106
|
+
"nvidia": ["NVIDIA_API_KEY"],
|
|
107
|
+
"azure_openai": ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT"],
|
|
108
|
+
"anthropic": ["ANTHROPIC_API_KEY"],
|
|
109
|
+
"vertex_ai": [("GOOGLE_OAUTH_ACCESS_TOKEN", "GCP_ACCESS_TOKEN"), "GOOGLE_CLOUD_PROJECT"],
|
|
110
|
+
"bedrock": [
|
|
111
|
+
("AWS_BEARER_TOKEN_BEDROCK", "AWS_ACCESS_KEY_ID"),
|
|
112
|
+
# When using access keys, secret key is required too.
|
|
113
|
+
("AWS_BEARER_TOKEN_BEDROCK", "AWS_SECRET_ACCESS_KEY"),
|
|
114
|
+
],
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _agent_progress_logger(
|
|
119
|
+
stage: str, *, status=None, verbose_steps: bool = True
|
|
120
|
+
) -> Callable[[dict], None]:
|
|
121
|
+
"""Render live progress with spinner-friendly status updates.
|
|
122
|
+
|
|
123
|
+
Frequent step events update the status line (fast, low-noise). Important
|
|
124
|
+
milestones (plan, approvals, errors) are printed.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def _set_status(message: str) -> None:
|
|
128
|
+
if status is None:
|
|
129
|
+
return
|
|
130
|
+
try:
|
|
131
|
+
status.update(message)
|
|
132
|
+
except Exception:
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
def _log(event: dict) -> None:
|
|
136
|
+
phase = str(event.get("phase", "agentic"))
|
|
137
|
+
kind = str(event.get("event", "update"))
|
|
138
|
+
if phase == "indexing":
|
|
139
|
+
if kind == "start":
|
|
140
|
+
_set_status(f"[cyan]{stage}[/cyan] indexing target...")
|
|
141
|
+
elif kind == "done":
|
|
142
|
+
_set_status(
|
|
143
|
+
f"[cyan]{stage}[/cyan] indexed {event.get('indexed_documents', 0)} document chunk(s)"
|
|
144
|
+
)
|
|
145
|
+
return
|
|
146
|
+
if phase == "baseline":
|
|
147
|
+
if kind == "start":
|
|
148
|
+
_set_status(
|
|
149
|
+
f"[cyan]{stage}[/cyan] baseline review over {event.get('batches', 0)} batch(es)"
|
|
150
|
+
)
|
|
151
|
+
elif kind == "batch_done":
|
|
152
|
+
_set_status(
|
|
153
|
+
f"[cyan]{stage}[/cyan] baseline batch {event.get('batch')}/{event.get('total_batches')} "
|
|
154
|
+
f"(findings: {event.get('findings_so_far', 0)})"
|
|
155
|
+
)
|
|
156
|
+
elif kind == "done":
|
|
157
|
+
_set_status(
|
|
158
|
+
f"[cyan]{stage}[/cyan] baseline complete ({event.get('floor_findings', 0)} finding(s))"
|
|
159
|
+
)
|
|
160
|
+
return
|
|
161
|
+
if phase != "agentic":
|
|
162
|
+
return
|
|
163
|
+
if kind == "start":
|
|
164
|
+
_set_status(
|
|
165
|
+
f"[cyan]{stage}[/cyan] agent loop started (max steps: {event.get('max_steps', 0)}; "
|
|
166
|
+
f"baseline: {event.get('floor_findings', 0)})"
|
|
167
|
+
)
|
|
168
|
+
elif kind == "plan_seed":
|
|
169
|
+
items = event.get("items", []) or []
|
|
170
|
+
if items:
|
|
171
|
+
console.print(f"[cyan]{stage}: agent plan[/cyan]")
|
|
172
|
+
for item in items[:6]:
|
|
173
|
+
console.print(f"[dim] - {item}[/dim]")
|
|
174
|
+
elif kind == "plan_proposed":
|
|
175
|
+
issues = event.get("issues", []) or []
|
|
176
|
+
if issues:
|
|
177
|
+
console.print(f"[cyan]{stage}: model plan proposal[/cyan]")
|
|
178
|
+
for issue in issues[:8]:
|
|
179
|
+
issue_id = issue.get("issue_id", "?")
|
|
180
|
+
sev = issue.get("severity", "?")
|
|
181
|
+
title = issue.get("title", "")
|
|
182
|
+
files = ", ".join(issue.get("files", [])[:3]) if isinstance(issue.get("files"), list) else ""
|
|
183
|
+
console.print(f"[dim] - [{sev}] {issue_id}: {title} ({files})[/dim]")
|
|
184
|
+
elif kind == "plan_locked":
|
|
185
|
+
ids = event.get("issue_ids", []) or []
|
|
186
|
+
if ids:
|
|
187
|
+
console.print(f"[green]{stage}: plan locked[/green] issue_ids={', '.join(ids)}")
|
|
188
|
+
elif kind == "phase_violation":
|
|
189
|
+
next_issue = str(event.get("next_issue", "") or "")
|
|
190
|
+
hint = f" Next: request_approval {next_issue}." if next_issue else ""
|
|
191
|
+
console.print(
|
|
192
|
+
f"[yellow]{stage}: exploration blocked after plan lock.{hint}[/yellow]"
|
|
193
|
+
)
|
|
194
|
+
elif kind == "plan_rejected":
|
|
195
|
+
console.print(
|
|
196
|
+
f"[yellow]{stage}: plan rejected[/yellow] ({event.get('reason', 'invalid plan')})."
|
|
197
|
+
)
|
|
198
|
+
elif kind == "approval_requested":
|
|
199
|
+
req = event.get("request", {}) or {}
|
|
200
|
+
console.print(
|
|
201
|
+
f"[magenta]{stage}: approval requested[/magenta] "
|
|
202
|
+
f"{req.get('issue_id', '?')} [{req.get('risk', 'medium')}] {req.get('title', '')}"
|
|
203
|
+
)
|
|
204
|
+
elif kind == "approval_decision":
|
|
205
|
+
status = "approved" if bool(event.get("approved")) else "denied"
|
|
206
|
+
color = "green" if status == "approved" else "yellow"
|
|
207
|
+
console.print(
|
|
208
|
+
f"[{color}]{stage}: approval {status}[/{color}] "
|
|
209
|
+
f"for issue {event.get('issue_id', '?')}"
|
|
210
|
+
)
|
|
211
|
+
elif kind == "tool_call":
|
|
212
|
+
action = str(event.get("action", "") or "")
|
|
213
|
+
reason = str(event.get("reason", "") or "")
|
|
214
|
+
confidence = event.get("confidence")
|
|
215
|
+
suffix = f" ({action})" if action else ""
|
|
216
|
+
reason_suffix = f" | why: {reason}" if reason else ""
|
|
217
|
+
conf_suffix = ""
|
|
218
|
+
if isinstance(confidence, (int, float)):
|
|
219
|
+
conf_suffix = f" | conf={max(0.0, min(1.0, float(confidence))):.2f}"
|
|
220
|
+
_set_status(
|
|
221
|
+
f"[cyan]{stage}[/cyan] step {event.get('step', '?')} -> `{event.get('tool', '?')}`{suffix}"
|
|
222
|
+
)
|
|
223
|
+
if verbose_steps:
|
|
224
|
+
console.print(
|
|
225
|
+
f"[dim]{stage}: step {event.get('step', '?')} -> tool `{event.get('tool', '?')}`"
|
|
226
|
+
f"{suffix}{reason_suffix}{conf_suffix}[/dim]"
|
|
227
|
+
)
|
|
228
|
+
elif kind == "tool_result":
|
|
229
|
+
summary = str(event.get("result", "") or "ok")
|
|
230
|
+
_set_status(f"[cyan]{stage}[/cyan] step {event.get('step', '?')} result -> {summary}")
|
|
231
|
+
if verbose_steps:
|
|
232
|
+
console.print(
|
|
233
|
+
f"[dim]{stage}: step {event.get('step', '?')} result -> {summary}[/dim]"
|
|
234
|
+
)
|
|
235
|
+
elif kind == "tool_error":
|
|
236
|
+
console.print(
|
|
237
|
+
f"[yellow]{stage}: step {event.get('step', '?')} tool `{event.get('tool', '?')}` "
|
|
238
|
+
f"error: {event.get('error', 'unknown')}[/yellow]"
|
|
239
|
+
)
|
|
240
|
+
elif kind == "invalid_response":
|
|
241
|
+
console.print(f"[yellow]{stage}: step {event.get('step', '?')} returned invalid JSON tool call.[/yellow]")
|
|
242
|
+
elif kind == "transport_error":
|
|
243
|
+
console.print(
|
|
244
|
+
f"[yellow]{stage}: model transport error at step {event.get('step', '?')}: "
|
|
245
|
+
f"{event.get('error', 'unknown')}[/yellow]"
|
|
246
|
+
)
|
|
247
|
+
elif kind == "finish":
|
|
248
|
+
_set_status(
|
|
249
|
+
f"[green]{stage}[/green] finish at step {event.get('step', '?')} "
|
|
250
|
+
f"(merged findings: {event.get('merged_findings', 0)})"
|
|
251
|
+
)
|
|
252
|
+
if verbose_steps:
|
|
253
|
+
console.print(
|
|
254
|
+
f"[dim]{stage}: finish at step {event.get('step', '?')} "
|
|
255
|
+
f"(merged findings: {event.get('merged_findings', 0)}).[/dim]"
|
|
256
|
+
)
|
|
257
|
+
elif kind == "ended_without_finish":
|
|
258
|
+
console.print(
|
|
259
|
+
f"[yellow]{stage}: agent reached step limit ({event.get('steps_used', 0)}) "
|
|
260
|
+
"without finish; keeping baseline findings.[/yellow]"
|
|
261
|
+
)
|
|
262
|
+
elif kind == "step_limit_reached":
|
|
263
|
+
_set_status(
|
|
264
|
+
f"[yellow]{stage}[/yellow] step limit reached "
|
|
265
|
+
f"({event.get('steps_used', 0)}/{event.get('max_steps', 0)})"
|
|
266
|
+
)
|
|
267
|
+
elif kind == "step_limit_extended":
|
|
268
|
+
console.print(
|
|
269
|
+
f"[green]{stage}: continuing[/green] "
|
|
270
|
+
f"step budget {event.get('old_max_steps', 0)} -> {event.get('new_max_steps', 0)}"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
return _log
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _approval_prompt(stage: str, *, auto_approve: bool) -> Callable[[dict], bool]:
|
|
277
|
+
"""Ask developer approval per issue for agentic code mode."""
|
|
278
|
+
|
|
279
|
+
def _ask(req: dict) -> bool:
|
|
280
|
+
if auto_approve:
|
|
281
|
+
return True
|
|
282
|
+
if not sys.stdin.isatty():
|
|
283
|
+
console.print(
|
|
284
|
+
f"[yellow]{stage}: non-interactive terminal; auto-denying issue "
|
|
285
|
+
f"{req.get('issue_id', '?')} (use --auto-approve to allow).[/yellow]"
|
|
286
|
+
)
|
|
287
|
+
return False
|
|
288
|
+
issue_id = str(req.get("issue_id", "?"))
|
|
289
|
+
title = str(req.get("title", "Untitled issue"))
|
|
290
|
+
summary = str(req.get("summary", "") or "")
|
|
291
|
+
risk = str(req.get("risk", "medium") or "medium")
|
|
292
|
+
console.print(f"\n[bold]Issue[/bold] {issue_id} [dim]risk={risk}[/dim]")
|
|
293
|
+
console.print(f"[bold]Title:[/bold] {title}")
|
|
294
|
+
if summary:
|
|
295
|
+
console.print(f"[bold]Plan:[/bold] {summary}")
|
|
296
|
+
console.print("[dim]Approval input: type 'y' to approve; type 'n' (or press Enter) to deny.[/dim]")
|
|
297
|
+
raw = typer.prompt("Approve this issue implementation? (y/N)", default="N")
|
|
298
|
+
return raw.strip().lower() in {"y", "yes"}
|
|
299
|
+
|
|
300
|
+
return _ask
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _rr_approval_prompt(*, auto_approve: bool) -> Callable[[dict], bool]:
|
|
304
|
+
"""Ask developer approval for a single RR proposal, showing the diff."""
|
|
305
|
+
|
|
306
|
+
def _ask(req: dict) -> bool:
|
|
307
|
+
attempt = int(req.get("attempt", 1) or 1)
|
|
308
|
+
location = str(req.get("location", "?"))
|
|
309
|
+
summary = str(req.get("summary", "") or "")
|
|
310
|
+
risk = str(req.get("risk", "medium") or "medium")
|
|
311
|
+
diff_text = str(req.get("diff_text", "") or "")
|
|
312
|
+
console.print(f"\n[bold]Proposed change[/bold] for [cyan]{location}[/cyan] "
|
|
313
|
+
f"[dim](attempt {attempt}/3, risk={risk})[/dim]")
|
|
314
|
+
if summary:
|
|
315
|
+
console.print(f"[bold]Change:[/bold] {summary}")
|
|
316
|
+
if diff_text:
|
|
317
|
+
console.print("[dim]--- diff preview ---[/dim]")
|
|
318
|
+
for line in diff_text.splitlines()[:60]:
|
|
319
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
320
|
+
console.print(f"[green]{line}[/green]")
|
|
321
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
322
|
+
console.print(f"[red]{line}[/red]")
|
|
323
|
+
else:
|
|
324
|
+
console.print(f"[dim]{line}[/dim]")
|
|
325
|
+
if auto_approve:
|
|
326
|
+
console.print("[dim]--auto-approve: applying without prompt.[/dim]")
|
|
327
|
+
return True
|
|
328
|
+
if not sys.stdin.isatty():
|
|
329
|
+
console.print("[yellow]non-interactive terminal; auto-denying (use --auto-approve to allow).[/yellow]")
|
|
330
|
+
return False
|
|
331
|
+
console.print("[dim]Type 'y' to apply this change; 'n' (or press Enter) to skip the issue.[/dim]")
|
|
332
|
+
raw = typer.prompt("Apply this change? (y/N)", default="N")
|
|
333
|
+
return raw.strip().lower() in {"y", "yes"}
|
|
334
|
+
|
|
335
|
+
return _ask
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _rr_progress_logger(status=None) -> Callable[[dict], None]:
|
|
339
|
+
"""Render the per-RR interactive flow with clear, dynamic messages."""
|
|
340
|
+
|
|
341
|
+
def _log(event: dict) -> None:
|
|
342
|
+
if event.get("phase") not in {"rr", "indexing", "baseline"}:
|
|
343
|
+
return
|
|
344
|
+
ev = event.get("event")
|
|
345
|
+
if ev == "run_start":
|
|
346
|
+
if status:
|
|
347
|
+
status.update("[cyan]code[/cyan] starting refactor requests...")
|
|
348
|
+
cmd = event.get("test_command") or "(none; syntax/LSP only)"
|
|
349
|
+
container = str(event.get("container_name", "") or "")
|
|
350
|
+
env = str(event.get("execution_env", "host") or "host")
|
|
351
|
+
console.print(
|
|
352
|
+
f"[bold cyan]code:[/bold cyan] {event.get('total_requests', 0)} "
|
|
353
|
+
f"refactor request(s); test command: [dim]{cmd}[/dim]"
|
|
354
|
+
)
|
|
355
|
+
if env == "sandbox":
|
|
356
|
+
console.print(
|
|
357
|
+
f"[bold cyan]code:[/bold cyan] running in sandbox: [dim]{cmd}[/dim] "
|
|
358
|
+
f"(container={container or '?'})"
|
|
359
|
+
)
|
|
360
|
+
elif ev == "rr_start":
|
|
361
|
+
if status:
|
|
362
|
+
status.update(f"[cyan]code[/cyan] working on {event.get('location', '?')}...")
|
|
363
|
+
console.print(f"\n[bold]\u2192 Working on issue[/bold] [cyan]{event.get('location', '?')}[/cyan]")
|
|
364
|
+
elif ev == "test_generating":
|
|
365
|
+
if status:
|
|
366
|
+
status.update("[cyan]code[/cyan] generating characterization test...")
|
|
367
|
+
console.print("[dim] generating characterization test...[/dim]")
|
|
368
|
+
elif ev == "baseline_test":
|
|
369
|
+
if status:
|
|
370
|
+
status.update("[cyan]code[/cyan] running baseline test...")
|
|
371
|
+
rr_id = str(event.get("rr_id", "?"))
|
|
372
|
+
outcome = "pass" if event.get("passed") else "fail"
|
|
373
|
+
env = str(event.get("execution_env", "host") or "host")
|
|
374
|
+
detail = str(event.get("detail", "") or "")
|
|
375
|
+
detail_snip = detail[:160] if detail else ""
|
|
376
|
+
console.print(
|
|
377
|
+
f"[dim] rr={rr_id} baseline test: {outcome} ({env})"
|
|
378
|
+
f"{' - ' + detail_snip if detail_snip else ''}[/dim]"
|
|
379
|
+
)
|
|
380
|
+
elif ev == "approval_requested":
|
|
381
|
+
if status:
|
|
382
|
+
status.update("[cyan]code[/cyan] awaiting developer approval...")
|
|
383
|
+
# Approval is interactive input; pause the spinner so the full
|
|
384
|
+
# prompt line is visible and not visually overwritten.
|
|
385
|
+
try:
|
|
386
|
+
status.stop()
|
|
387
|
+
except Exception:
|
|
388
|
+
pass
|
|
389
|
+
elif ev == "approval_decision":
|
|
390
|
+
if status:
|
|
391
|
+
# Resume spinner after the interactive prompt returns.
|
|
392
|
+
try:
|
|
393
|
+
status.start()
|
|
394
|
+
except Exception:
|
|
395
|
+
pass
|
|
396
|
+
status.update("[cyan]code[/cyan] applying approved change...")
|
|
397
|
+
elif ev == "rr_applied":
|
|
398
|
+
if status:
|
|
399
|
+
status.update("[green]code[/green] change verified, moving to next issue...")
|
|
400
|
+
files = ", ".join(event.get("files", []) or [])
|
|
401
|
+
console.print(f"[green] \u2713 applied & verified[/green] [dim]{files}[/dim]")
|
|
402
|
+
elif ev == "rr_reverted":
|
|
403
|
+
if status:
|
|
404
|
+
status.update("[yellow]code[/yellow] verification failed; reverting and retrying...")
|
|
405
|
+
console.print(
|
|
406
|
+
f"[yellow] \u21ba reverted (attempt {event.get('attempt')}): "
|
|
407
|
+
f"{event.get('detail', '')[:160]}[/yellow]"
|
|
408
|
+
)
|
|
409
|
+
elif ev == "proposal_invalid":
|
|
410
|
+
if status:
|
|
411
|
+
status.update("[yellow]code[/yellow] proposal invalid; retrying...")
|
|
412
|
+
console.print(
|
|
413
|
+
f"[yellow] proposal {event.get('attempt')} invalid: {event.get('reason', '')}[/yellow]"
|
|
414
|
+
)
|
|
415
|
+
elif ev == "preflight_failed":
|
|
416
|
+
if status:
|
|
417
|
+
status.update("[red]code[/red] preflight failed.")
|
|
418
|
+
console.print(f"[red] ✗ preflight failed:[/red] {event.get('reason', '')}")
|
|
419
|
+
elif ev == "run_stopped":
|
|
420
|
+
if status:
|
|
421
|
+
status.update("[red]code[/red] stopped after repeated failures.")
|
|
422
|
+
console.print(f"[red] \u2717 stopped:[/red] {event.get('reason', '')}")
|
|
423
|
+
elif ev == "run_done":
|
|
424
|
+
if status:
|
|
425
|
+
status.update("[green]code[/green] completed.")
|
|
426
|
+
|
|
427
|
+
return _log
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _step_limit_prompt(stage: str) -> Callable[[dict], bool]:
|
|
431
|
+
"""Ask whether to continue when agentic max-step budget is exhausted."""
|
|
432
|
+
|
|
433
|
+
def _ask(info: dict) -> bool:
|
|
434
|
+
steps_used = int(info.get("steps_used", 0) or 0)
|
|
435
|
+
max_steps = int(info.get("max_steps", 0) or 0)
|
|
436
|
+
extension = int(info.get("default_extension", max_steps) or max_steps or 1)
|
|
437
|
+
if not sys.stdin.isatty():
|
|
438
|
+
console.print(
|
|
439
|
+
f"[yellow]{stage}: step limit reached ({steps_used}/{max_steps}) in non-interactive terminal; "
|
|
440
|
+
"stopping. Re-run with higher `max_agent_steps` to continue.[/yellow]"
|
|
441
|
+
)
|
|
442
|
+
return False
|
|
443
|
+
console.print(
|
|
444
|
+
f"[yellow]{stage}: reached step limit ({steps_used}/{max_steps}).[/yellow] "
|
|
445
|
+
f"Increase by {extension} steps and continue?"
|
|
446
|
+
)
|
|
447
|
+
raw = typer.prompt("Continue agent loop? (y/N)", default="N")
|
|
448
|
+
return raw.strip().lower() in {"y", "yes"}
|
|
449
|
+
|
|
450
|
+
return _ask
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _require_auth() -> None:
|
|
454
|
+
try:
|
|
455
|
+
ensure_authenticated()
|
|
456
|
+
except AuthError as exc:
|
|
457
|
+
console.print(f"[red]Blocked:[/red] {exc}")
|
|
458
|
+
raise typer.Exit(code=1) from exc
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _is_cloud_mode(constitution) -> bool:
|
|
462
|
+
return str(constitution.get_setting("mode", "local") or "local").strip().lower() == "cloud_managed"
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _cloud_inference(
|
|
466
|
+
*,
|
|
467
|
+
stage: str,
|
|
468
|
+
project_root: Path,
|
|
469
|
+
constitution,
|
|
470
|
+
target: str,
|
|
471
|
+
model_hint: str | None = None,
|
|
472
|
+
) -> RunArtifact:
|
|
473
|
+
max_alerts = int(constitution.get_setting("max_alerts_per_run", 5) or 5)
|
|
474
|
+
gate_mode = str(constitution.get_setting("gate_mode", "strict") or "strict")
|
|
475
|
+
excludes = set(constitution.get_setting("exclude", []) or [])
|
|
476
|
+
documents = index_target(Path(project_root), target, excludes=excludes)
|
|
477
|
+
documents, _scope_meta = filter_documents_by_rules(documents, project_root=Path(project_root))
|
|
478
|
+
rule_meta = annotate_documents_with_rules(documents, project_root=Path(project_root))
|
|
479
|
+
|
|
480
|
+
if stage == "review":
|
|
481
|
+
system_prompt, user_prompt = build_review_prompt(constitution, documents, max_alerts)
|
|
482
|
+
else:
|
|
483
|
+
system_prompt, user_prompt = build_refactor_prompt(constitution, documents, [], max_alerts)
|
|
484
|
+
|
|
485
|
+
auth_ctx = ensure_authenticated(project_root)
|
|
486
|
+
payload = {
|
|
487
|
+
"stage": stage,
|
|
488
|
+
"constitution_hash": constitution.content_hash,
|
|
489
|
+
"system_prompt": system_prompt,
|
|
490
|
+
"user_prompt": user_prompt,
|
|
491
|
+
"max_alerts": max_alerts,
|
|
492
|
+
"model_hint": model_hint or str(constitution.get_setting("model_id", "managed-default") or "managed-default"),
|
|
493
|
+
}
|
|
494
|
+
try:
|
|
495
|
+
response = httpx.post(
|
|
496
|
+
f"{platform_url()}/v1/inference/refactor",
|
|
497
|
+
headers={
|
|
498
|
+
"Authorization": f"Bearer {auth_ctx.key.key}",
|
|
499
|
+
"Content-Type": "application/json",
|
|
500
|
+
"Idempotency-Key": uuid.uuid4().hex,
|
|
501
|
+
},
|
|
502
|
+
json=payload,
|
|
503
|
+
timeout=45.0,
|
|
504
|
+
)
|
|
505
|
+
except httpx.HTTPError as exc:
|
|
506
|
+
raise RuntimeError(f"Could not reach managed inference service: {exc}") from exc
|
|
507
|
+
|
|
508
|
+
if response.status_code == 401:
|
|
509
|
+
raise RuntimeError("Cloud inference unauthorized (developer key invalid or revoked).")
|
|
510
|
+
if response.status_code == 402:
|
|
511
|
+
raise RuntimeError("Cloud inference requires paid tier (`mode: cloud_managed`).")
|
|
512
|
+
if response.status_code == 429:
|
|
513
|
+
raise RuntimeError("Cloud inference quota exhausted or rate-limited.")
|
|
514
|
+
if response.status_code >= 400:
|
|
515
|
+
raise RuntimeError(f"Cloud inference failed ({response.status_code}): {response.text[:500]}")
|
|
516
|
+
|
|
517
|
+
data = response.json() if response.content else {}
|
|
518
|
+
findings = [Finding.model_validate(item) for item in (data.get("findings") or [])][:max_alerts]
|
|
519
|
+
patches = [PatchGroup.model_validate(item) for item in (data.get("patches") or [])][:max_alerts]
|
|
520
|
+
generated_tests = [
|
|
521
|
+
GeneratedTest.model_validate(item) for item in (data.get("generated_tests") or [])
|
|
522
|
+
][:max_alerts]
|
|
523
|
+
gate = evaluate_gate(findings, [], mode=gate_mode)
|
|
524
|
+
|
|
525
|
+
run_id = uuid.uuid4().hex[:12]
|
|
526
|
+
if stage == "review":
|
|
527
|
+
summary = (
|
|
528
|
+
f"Cloud review of '{target}' produced {len(findings)} finding(s); "
|
|
529
|
+
f"gate={gate.status} (high_findings={gate.high_severity_findings})."
|
|
530
|
+
)
|
|
531
|
+
else:
|
|
532
|
+
summary = (
|
|
533
|
+
f"Cloud refactor of '{target}': {len(findings)} finding(s), {len(patches)} patch group(s), "
|
|
534
|
+
f"{len(generated_tests)} generated test(s); gate={gate.status}."
|
|
535
|
+
)
|
|
536
|
+
return RunArtifact(
|
|
537
|
+
run_id=run_id,
|
|
538
|
+
findings=findings,
|
|
539
|
+
patches=patches,
|
|
540
|
+
generated_tests=generated_tests,
|
|
541
|
+
gate=gate,
|
|
542
|
+
summary=summary,
|
|
543
|
+
constitution_hash=constitution.content_hash,
|
|
544
|
+
model_id=str(data.get("model_id") or payload["model_hint"]),
|
|
545
|
+
rule_source=rule_meta.get("rule_source", ""),
|
|
546
|
+
rule_pattern=rule_meta.get("rule_pattern", ""),
|
|
547
|
+
rule_hash=rule_meta.get("rule_hash", ""),
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _load_project() -> tuple[Path, object, object]:
|
|
552
|
+
files = find_project_files()
|
|
553
|
+
if not files:
|
|
554
|
+
console.print(
|
|
555
|
+
f"[yellow]Missing {CONSTITUTION_FILENAME} or {CONFIG_FILENAME}.[/yellow] "
|
|
556
|
+
"Run `refactor init` first."
|
|
557
|
+
)
|
|
558
|
+
raise typer.Exit(code=1)
|
|
559
|
+
consti_path, config_path = files
|
|
560
|
+
constitution = load_constitution(consti_path)
|
|
561
|
+
config = load_config(config_path)
|
|
562
|
+
_warn_raw_key(constitution, config)
|
|
563
|
+
return consti_path.parent, constitution, config
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _warn_raw_key(constitution, config) -> None:
|
|
567
|
+
if has_raw_secret_literal(constitution) or bool(raw_secret_matches_config(config)):
|
|
568
|
+
console.print(
|
|
569
|
+
"[yellow]Warning:[/yellow] literal credential-like value(s) appear in "
|
|
570
|
+
f"{CONSTITUTION_FILENAME} or {CONFIG_FILENAME}. "
|
|
571
|
+
"Prefer `${ENV_VAR}` references and keep secrets out of "
|
|
572
|
+
"version control. Run `refactor check --strict` in CI to enforce."
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def init(
|
|
577
|
+
force: bool = typer.Option(
|
|
578
|
+
False,
|
|
579
|
+
"--force",
|
|
580
|
+
help=f"Overwrite existing {CONSTITUTION_FILENAME} and {CONFIG_FILENAME}.",
|
|
581
|
+
),
|
|
582
|
+
) -> None:
|
|
583
|
+
"""Create `refactor.consti` + `refactor.config` and register the project."""
|
|
584
|
+
try:
|
|
585
|
+
project_root = Path.cwd()
|
|
586
|
+
except OSError:
|
|
587
|
+
console.print(
|
|
588
|
+
"[red]Could not resolve the current project directory.[/red] "
|
|
589
|
+
"Please switch to a valid directory and retry. "
|
|
590
|
+
"If the problem continues, update/reinstall Refactor."
|
|
591
|
+
)
|
|
592
|
+
raise typer.Exit(code=1) from None
|
|
593
|
+
|
|
594
|
+
consti_target = project_root / CONSTITUTION_FILENAME
|
|
595
|
+
config_target = project_root / CONFIG_FILENAME
|
|
596
|
+
if (consti_target.exists() or config_target.exists()) and not force:
|
|
597
|
+
console.print(
|
|
598
|
+
f"[yellow]{CONSTITUTION_FILENAME} or {CONFIG_FILENAME} already exists.[/yellow] "
|
|
599
|
+
"Use --force to overwrite."
|
|
600
|
+
)
|
|
601
|
+
raise typer.Exit(code=1)
|
|
602
|
+
consti_target.write_text(DEFAULT_CONSTITUTION, encoding="utf-8")
|
|
603
|
+
config_target.write_text(DEFAULT_CONFIG, encoding="utf-8")
|
|
604
|
+
|
|
605
|
+
constitution = load_constitution(consti_target)
|
|
606
|
+
record = register_project(project_root, constitution_hash=constitution.content_hash)
|
|
607
|
+
console.print(f"[green]Created[/green] {consti_target}")
|
|
608
|
+
console.print(f"[green]Created[/green] {config_target}")
|
|
609
|
+
console.print(
|
|
610
|
+
f"[green]Registered[/green] project in central store: {project_store_dir(project_root)}"
|
|
611
|
+
)
|
|
612
|
+
console.print(
|
|
613
|
+
f"Only {CONSTITUTION_FILENAME} and {CONFIG_FILENAME} are added to your repo; "
|
|
614
|
+
"all run state stays under ~/.refactor "
|
|
615
|
+
f"(encoded: {record['encoded']})."
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
_ENV_ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*$")
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _command_binary(command: str) -> str | None:
|
|
623
|
+
"""Extract the executable from a shell command string."""
|
|
624
|
+
if not command:
|
|
625
|
+
return None
|
|
626
|
+
try:
|
|
627
|
+
tokens = shlex.split(command, posix=True)
|
|
628
|
+
except ValueError:
|
|
629
|
+
return None
|
|
630
|
+
while tokens and _ENV_ASSIGN_RE.match(tokens[0]):
|
|
631
|
+
tokens.pop(0)
|
|
632
|
+
return tokens[0] if tokens else None
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _install_plan_for_missing_binary(
|
|
636
|
+
*, project_root: Path, binary: str, installer: str | None
|
|
637
|
+
) -> list[str]:
|
|
638
|
+
"""Return a conservative install plan for missing toolchain commands."""
|
|
639
|
+
if not installer:
|
|
640
|
+
return []
|
|
641
|
+
pkg_cmd = ""
|
|
642
|
+
if installer == "apk":
|
|
643
|
+
if binary in {"npm", "node"}:
|
|
644
|
+
pkg_cmd = "apk add --no-cache nodejs npm"
|
|
645
|
+
elif binary in {"python", "python3", "pip", "pip3", "pytest"}:
|
|
646
|
+
pkg_cmd = "apk add --no-cache python3 py3-pip"
|
|
647
|
+
elif installer == "apt-get":
|
|
648
|
+
if binary in {"npm", "node"}:
|
|
649
|
+
pkg_cmd = "apt-get update && apt-get install -y nodejs npm"
|
|
650
|
+
elif binary in {"python", "python3", "pip", "pip3", "pytest"}:
|
|
651
|
+
pkg_cmd = "apt-get update && apt-get install -y python3 python3-pip"
|
|
652
|
+
elif installer in {"dnf", "yum"}:
|
|
653
|
+
if binary in {"npm", "node"}:
|
|
654
|
+
pkg_cmd = f"{installer} install -y nodejs npm"
|
|
655
|
+
elif binary in {"python", "python3", "pip", "pip3", "pytest"}:
|
|
656
|
+
pkg_cmd = f"{installer} install -y python3 python3-pip"
|
|
657
|
+
plan: list[str] = []
|
|
658
|
+
if pkg_cmd:
|
|
659
|
+
plan.append(pkg_cmd)
|
|
660
|
+
# Dependency restore after toolchain install (best effort).
|
|
661
|
+
if (project_root / "package.json").is_file():
|
|
662
|
+
plan.append("npm install")
|
|
663
|
+
elif (project_root / "requirements.txt").is_file():
|
|
664
|
+
plan.append("pip install -r requirements.txt")
|
|
665
|
+
return plan
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _node_toolchain_install(installer: str | None) -> str | None:
|
|
669
|
+
if installer == "apk":
|
|
670
|
+
return "apk add --no-cache nodejs npm"
|
|
671
|
+
if installer == "apt-get":
|
|
672
|
+
return "apt-get update && apt-get install -y nodejs npm"
|
|
673
|
+
if installer in {"dnf", "yum"}:
|
|
674
|
+
return f"{installer} install -y nodejs npm"
|
|
675
|
+
return None
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _go_toolchain_install(installer: str | None) -> str | None:
|
|
679
|
+
if installer == "apk":
|
|
680
|
+
return "apk add --no-cache go"
|
|
681
|
+
if installer == "apt-get":
|
|
682
|
+
return "apt-get update && apt-get install -y golang-go"
|
|
683
|
+
if installer in {"dnf", "yum"}:
|
|
684
|
+
return f"{installer} install -y golang"
|
|
685
|
+
return None
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _project_languages(project_root: Path) -> set[str]:
|
|
689
|
+
"""Infer likely project languages from common manifest/build markers."""
|
|
690
|
+
langs: set[str] = set()
|
|
691
|
+
if (project_root / "package.json").is_file():
|
|
692
|
+
langs.update({"javascript", "typescript"})
|
|
693
|
+
if (project_root / "tsconfig.json").is_file():
|
|
694
|
+
langs.add("typescript")
|
|
695
|
+
if any((project_root / name).is_file() for name in ("requirements.txt", "pyproject.toml", "setup.py", "Pipfile")):
|
|
696
|
+
langs.add("python")
|
|
697
|
+
if (project_root / "go.mod").is_file():
|
|
698
|
+
langs.add("go")
|
|
699
|
+
if (project_root / "Cargo.toml").is_file():
|
|
700
|
+
langs.add("rust")
|
|
701
|
+
if any((project_root / name).is_file() for name in ("pom.xml", "build.gradle", "build.gradle.kts")):
|
|
702
|
+
langs.add("java")
|
|
703
|
+
if list(project_root.glob("*.csproj")) or list(project_root.glob("*.sln")):
|
|
704
|
+
langs.add("csharp")
|
|
705
|
+
if any((project_root / name).is_file() for name in ("CMakeLists.txt", "compile_commands.json")):
|
|
706
|
+
langs.update({"c", "cpp"})
|
|
707
|
+
return langs
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _required_lsp_commands(project_root: Path) -> dict[str, str]:
|
|
711
|
+
"""Return required LSP command -> friendly name based on inferred languages."""
|
|
712
|
+
from refactor_core.codeintel.lsp import LSP_SERVERS
|
|
713
|
+
|
|
714
|
+
required: dict[str, str] = {}
|
|
715
|
+
for language in sorted(_project_languages(project_root)):
|
|
716
|
+
entry = LSP_SERVERS.get(language)
|
|
717
|
+
if not entry:
|
|
718
|
+
continue
|
|
719
|
+
command, name = entry
|
|
720
|
+
required[command] = name
|
|
721
|
+
return required
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _install_plan_for_missing_lsp(
|
|
725
|
+
*,
|
|
726
|
+
missing_commands: list[str],
|
|
727
|
+
installer: str | None,
|
|
728
|
+
) -> tuple[list[str], list[str]]:
|
|
729
|
+
"""Return (install_plan, unsupported_commands) for missing LSP binaries."""
|
|
730
|
+
plan: list[str] = []
|
|
731
|
+
unsupported: list[str] = []
|
|
732
|
+
for command in missing_commands:
|
|
733
|
+
if command == "typescript-language-server":
|
|
734
|
+
prereq = _node_toolchain_install(installer)
|
|
735
|
+
if prereq:
|
|
736
|
+
plan.append(prereq)
|
|
737
|
+
plan.append("npm install -g typescript typescript-language-server")
|
|
738
|
+
continue
|
|
739
|
+
if command == "pyright-langserver":
|
|
740
|
+
prereq = _node_toolchain_install(installer)
|
|
741
|
+
if prereq:
|
|
742
|
+
plan.append(prereq)
|
|
743
|
+
plan.append("npm install -g pyright")
|
|
744
|
+
continue
|
|
745
|
+
if command == "gopls":
|
|
746
|
+
prereq = _go_toolchain_install(installer)
|
|
747
|
+
if prereq:
|
|
748
|
+
plan.append(prereq)
|
|
749
|
+
plan.append("GOBIN=/usr/local/bin go install golang.org/x/tools/gopls@latest")
|
|
750
|
+
else:
|
|
751
|
+
unsupported.append(command)
|
|
752
|
+
continue
|
|
753
|
+
if command == "rust-analyzer":
|
|
754
|
+
if installer == "apk":
|
|
755
|
+
plan.append("apk add --no-cache rust-analyzer")
|
|
756
|
+
elif installer == "apt-get":
|
|
757
|
+
plan.append("apt-get update && apt-get install -y rust-analyzer")
|
|
758
|
+
elif installer in {"dnf", "yum"}:
|
|
759
|
+
plan.append(f"{installer} install -y rust-analyzer")
|
|
760
|
+
else:
|
|
761
|
+
unsupported.append(command)
|
|
762
|
+
continue
|
|
763
|
+
if command == "clangd":
|
|
764
|
+
if installer == "apk":
|
|
765
|
+
plan.append("apk add --no-cache clang-extra-tools")
|
|
766
|
+
elif installer == "apt-get":
|
|
767
|
+
plan.append("apt-get update && apt-get install -y clangd")
|
|
768
|
+
elif installer in {"dnf", "yum"}:
|
|
769
|
+
plan.append(f"{installer} install -y clang-tools-extra")
|
|
770
|
+
else:
|
|
771
|
+
unsupported.append(command)
|
|
772
|
+
continue
|
|
773
|
+
if command == "jdtls":
|
|
774
|
+
if installer == "apk":
|
|
775
|
+
plan.append("apk add --no-cache openjdk17-jre")
|
|
776
|
+
elif installer == "apt-get":
|
|
777
|
+
plan.append("apt-get update && apt-get install -y openjdk-17-jre-headless")
|
|
778
|
+
plan.append("apt-get update && apt-get install -y jdtls")
|
|
779
|
+
elif installer in {"dnf", "yum"}:
|
|
780
|
+
plan.append(f"{installer} install -y java-17-openjdk")
|
|
781
|
+
else:
|
|
782
|
+
unsupported.append(command)
|
|
783
|
+
continue
|
|
784
|
+
if command == "omnisharp":
|
|
785
|
+
# Distribution install varies widely; keep explicit unsupported for now.
|
|
786
|
+
unsupported.append(command)
|
|
787
|
+
continue
|
|
788
|
+
unsupported.append(command)
|
|
789
|
+
# Deduplicate while preserving order.
|
|
790
|
+
dedup_plan = list(dict.fromkeys(plan))
|
|
791
|
+
return dedup_plan, unsupported
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _host_runtime_install_plan(runtime: str) -> list[str]:
|
|
795
|
+
"""Return host-level install commands for a missing runtime binary."""
|
|
796
|
+
rt = (runtime or "").strip().lower()
|
|
797
|
+
if rt != "podman":
|
|
798
|
+
return []
|
|
799
|
+
if shutil.which("apt-get"):
|
|
800
|
+
return ["sudo apt-get update", "sudo apt-get install -y podman"]
|
|
801
|
+
if shutil.which("dnf"):
|
|
802
|
+
return ["sudo dnf install -y podman"]
|
|
803
|
+
if shutil.which("yum"):
|
|
804
|
+
return ["sudo yum install -y podman"]
|
|
805
|
+
if shutil.which("apk"):
|
|
806
|
+
return ["sudo apk add --no-cache podman"]
|
|
807
|
+
if shutil.which("pacman"):
|
|
808
|
+
return ["sudo pacman -Sy --noconfirm podman"]
|
|
809
|
+
if shutil.which("brew"):
|
|
810
|
+
return ["brew install podman"]
|
|
811
|
+
return []
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _maybe_install_host_runtime(
|
|
815
|
+
*,
|
|
816
|
+
runtime: str,
|
|
817
|
+
reason: str,
|
|
818
|
+
auto_approve: bool,
|
|
819
|
+
) -> tuple[bool, str]:
|
|
820
|
+
"""Ask approval, install missing host runtime, and return status."""
|
|
821
|
+
plan = _host_runtime_install_plan(runtime)
|
|
822
|
+
if not plan:
|
|
823
|
+
return False, reason
|
|
824
|
+
|
|
825
|
+
console.print(f"[yellow]{reason}[/yellow]")
|
|
826
|
+
console.print(f"[bold]Host install plan for `{runtime}`:[/bold]")
|
|
827
|
+
for cmd in plan:
|
|
828
|
+
console.print(f"[dim] - {cmd}[/dim]")
|
|
829
|
+
|
|
830
|
+
approved = auto_approve
|
|
831
|
+
if not auto_approve:
|
|
832
|
+
if not sys.stdin.isatty():
|
|
833
|
+
return (
|
|
834
|
+
False,
|
|
835
|
+
"Host runtime install plan requires approval in non-interactive mode "
|
|
836
|
+
"(use `refactor start --yes` to auto-approve, or install manually).",
|
|
837
|
+
)
|
|
838
|
+
raw = typer.prompt(f"Install {runtime} on host? (y/N)", default="N")
|
|
839
|
+
approved = raw.strip().lower() in {"y", "yes"}
|
|
840
|
+
if not approved:
|
|
841
|
+
return False, f"{runtime} install not approved."
|
|
842
|
+
|
|
843
|
+
for cmd in plan:
|
|
844
|
+
proc = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
|
845
|
+
if proc.returncode != 0:
|
|
846
|
+
detail = (proc.stderr or proc.stdout or "").strip()[:500]
|
|
847
|
+
return False, f"Host install failed: `{cmd}` -> {detail}"
|
|
848
|
+
return True, f"{runtime} installed on host."
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _sandbox_preflight(
|
|
852
|
+
*,
|
|
853
|
+
project_root: Path,
|
|
854
|
+
config_settings: dict,
|
|
855
|
+
auto_approve: bool,
|
|
856
|
+
no_install: bool,
|
|
857
|
+
) -> tuple[bool, str]:
|
|
858
|
+
"""Run sandbox preflight and optionally perform approved install plan."""
|
|
859
|
+
test_command = resolve_test_command(project_root, config_settings)
|
|
860
|
+
required_lsp = _required_lsp_commands(project_root)
|
|
861
|
+
missing_lsp = [
|
|
862
|
+
cmd for cmd in sorted(required_lsp) if not command_exists_in_sandbox(project_root=project_root, binary=cmd)
|
|
863
|
+
]
|
|
864
|
+
missing_binary = ""
|
|
865
|
+
test_command_present = ""
|
|
866
|
+
if test_command:
|
|
867
|
+
binary = _command_binary(test_command)
|
|
868
|
+
if not binary:
|
|
869
|
+
write_sandbox_preflight(
|
|
870
|
+
project_root,
|
|
871
|
+
{
|
|
872
|
+
"status": "blocked",
|
|
873
|
+
"reason": "unparseable_test_command",
|
|
874
|
+
"test_command": test_command,
|
|
875
|
+
"missing_lsp_servers": missing_lsp,
|
|
876
|
+
},
|
|
877
|
+
)
|
|
878
|
+
return False, f"Could not parse test command: {test_command!r}"
|
|
879
|
+
test_command_present = binary
|
|
880
|
+
if not command_exists_in_sandbox(project_root=project_root, binary=binary):
|
|
881
|
+
missing_binary = binary
|
|
882
|
+
|
|
883
|
+
if not missing_binary and not missing_lsp:
|
|
884
|
+
write_sandbox_preflight(
|
|
885
|
+
project_root,
|
|
886
|
+
{
|
|
887
|
+
"status": "passed",
|
|
888
|
+
"reason": "requirements_present",
|
|
889
|
+
"test_command": test_command or "",
|
|
890
|
+
"required_binary": test_command_present,
|
|
891
|
+
"required_lsp_servers": sorted(required_lsp),
|
|
892
|
+
"missing_lsp_servers": [],
|
|
893
|
+
},
|
|
894
|
+
)
|
|
895
|
+
if not test_command:
|
|
896
|
+
return True, "No test command configured."
|
|
897
|
+
return True, f"Preflight ok: found '{test_command_present}' and required LSP server(s) in sandbox."
|
|
898
|
+
|
|
899
|
+
installer = detect_installer_in_sandbox(project_root=project_root)
|
|
900
|
+
plan: list[str] = []
|
|
901
|
+
if missing_binary:
|
|
902
|
+
plan.extend(
|
|
903
|
+
_install_plan_for_missing_binary(
|
|
904
|
+
project_root=project_root, binary=missing_binary, installer=installer
|
|
905
|
+
)
|
|
906
|
+
)
|
|
907
|
+
lsp_plan, unsupported_lsp = _install_plan_for_missing_lsp(
|
|
908
|
+
missing_commands=missing_lsp, installer=installer
|
|
909
|
+
)
|
|
910
|
+
plan.extend(lsp_plan)
|
|
911
|
+
plan = list(dict.fromkeys(plan))
|
|
912
|
+
if no_install:
|
|
913
|
+
write_sandbox_preflight(
|
|
914
|
+
project_root,
|
|
915
|
+
{
|
|
916
|
+
"status": "blocked",
|
|
917
|
+
"reason": "missing_binary_no_install",
|
|
918
|
+
"test_command": test_command or "",
|
|
919
|
+
"required_binary": missing_binary,
|
|
920
|
+
"install_plan": plan,
|
|
921
|
+
"missing_lsp_servers": missing_lsp,
|
|
922
|
+
"unsupported_lsp_servers": unsupported_lsp,
|
|
923
|
+
},
|
|
924
|
+
)
|
|
925
|
+
return (
|
|
926
|
+
False,
|
|
927
|
+
"Preflight failed with missing runtime requirements (test command and/or LSP servers). "
|
|
928
|
+
"Re-run without --no-install to approve and execute an install plan.",
|
|
929
|
+
)
|
|
930
|
+
if not plan and (missing_binary or missing_lsp):
|
|
931
|
+
write_sandbox_preflight(
|
|
932
|
+
project_root,
|
|
933
|
+
{
|
|
934
|
+
"status": "blocked",
|
|
935
|
+
"reason": "missing_binary_no_plan",
|
|
936
|
+
"test_command": test_command or "",
|
|
937
|
+
"required_binary": missing_binary,
|
|
938
|
+
"missing_lsp_servers": missing_lsp,
|
|
939
|
+
"unsupported_lsp_servers": unsupported_lsp,
|
|
940
|
+
},
|
|
941
|
+
)
|
|
942
|
+
return (
|
|
943
|
+
False,
|
|
944
|
+
"Preflight failed: missing test/LSP requirements and no install plan is available. "
|
|
945
|
+
"Configure sandbox image/dependencies, then retry.",
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
if missing_binary:
|
|
949
|
+
console.print(
|
|
950
|
+
f"[yellow]Preflight:[/yellow] missing required command [bold]{missing_binary}[/bold] "
|
|
951
|
+
f"for test command `{test_command}`."
|
|
952
|
+
)
|
|
953
|
+
if missing_lsp:
|
|
954
|
+
names = ", ".join(required_lsp.get(cmd, cmd) for cmd in missing_lsp)
|
|
955
|
+
console.print(f"[yellow]Preflight:[/yellow] missing LSP server(s): {names}")
|
|
956
|
+
if unsupported_lsp:
|
|
957
|
+
names = ", ".join(required_lsp.get(cmd, cmd) for cmd in unsupported_lsp)
|
|
958
|
+
console.print(
|
|
959
|
+
f"[yellow]Note:[/yellow] no automatic install recipe for: {names}. "
|
|
960
|
+
"Install manually in sandbox image if needed."
|
|
961
|
+
)
|
|
962
|
+
console.print("[bold]Install plan (sandbox):[/bold]")
|
|
963
|
+
for cmd in plan:
|
|
964
|
+
console.print(f"[dim] - {cmd}[/dim]")
|
|
965
|
+
|
|
966
|
+
approved = auto_approve
|
|
967
|
+
if not auto_approve:
|
|
968
|
+
if not sys.stdin.isatty():
|
|
969
|
+
return (
|
|
970
|
+
False,
|
|
971
|
+
"Preflight failed and install approval is required in non-interactive terminal "
|
|
972
|
+
"(use --yes to auto-approve).",
|
|
973
|
+
)
|
|
974
|
+
raw = typer.prompt("Approve install plan? (y/N)", default="N")
|
|
975
|
+
approved = raw.strip().lower() in {"y", "yes"}
|
|
976
|
+
if not approved:
|
|
977
|
+
write_sandbox_preflight(
|
|
978
|
+
project_root,
|
|
979
|
+
{
|
|
980
|
+
"status": "blocked",
|
|
981
|
+
"reason": "install_plan_denied",
|
|
982
|
+
"test_command": test_command or "",
|
|
983
|
+
"required_binary": missing_binary,
|
|
984
|
+
"install_plan": plan,
|
|
985
|
+
"missing_lsp_servers": missing_lsp,
|
|
986
|
+
"unsupported_lsp_servers": unsupported_lsp,
|
|
987
|
+
},
|
|
988
|
+
)
|
|
989
|
+
append_sandbox_install_history(
|
|
990
|
+
project_root,
|
|
991
|
+
{
|
|
992
|
+
"approved": False,
|
|
993
|
+
"test_command": test_command or "",
|
|
994
|
+
"required_binary": missing_binary,
|
|
995
|
+
"missing_lsp_servers": missing_lsp,
|
|
996
|
+
"plan": plan,
|
|
997
|
+
},
|
|
998
|
+
)
|
|
999
|
+
return False, "Install plan not approved; preflight remains blocked."
|
|
1000
|
+
|
|
1001
|
+
entry = {
|
|
1002
|
+
"approved": True,
|
|
1003
|
+
"test_command": test_command or "",
|
|
1004
|
+
"required_binary": missing_binary,
|
|
1005
|
+
"missing_lsp_servers": missing_lsp,
|
|
1006
|
+
"plan": plan,
|
|
1007
|
+
"results": [],
|
|
1008
|
+
}
|
|
1009
|
+
for cmd in plan:
|
|
1010
|
+
ok, output = exec_in_sandbox(project_root=project_root, command=cmd)
|
|
1011
|
+
entry["results"].append({"command": cmd, "ok": ok, "output": (output or "")[:500]})
|
|
1012
|
+
if not ok:
|
|
1013
|
+
detail = output[:400] if output else "unknown error"
|
|
1014
|
+
append_sandbox_install_history(project_root, entry)
|
|
1015
|
+
write_sandbox_preflight(
|
|
1016
|
+
project_root,
|
|
1017
|
+
{
|
|
1018
|
+
"status": "blocked",
|
|
1019
|
+
"reason": "install_command_failed",
|
|
1020
|
+
"test_command": test_command or "",
|
|
1021
|
+
"required_binary": missing_binary,
|
|
1022
|
+
"install_plan": plan,
|
|
1023
|
+
"missing_lsp_servers": missing_lsp,
|
|
1024
|
+
"unsupported_lsp_servers": unsupported_lsp,
|
|
1025
|
+
},
|
|
1026
|
+
)
|
|
1027
|
+
return False, f"Install command failed: `{cmd}` -> {detail}"
|
|
1028
|
+
append_sandbox_install_history(project_root, entry)
|
|
1029
|
+
|
|
1030
|
+
if missing_binary and not command_exists_in_sandbox(project_root=project_root, binary=missing_binary):
|
|
1031
|
+
write_sandbox_preflight(
|
|
1032
|
+
project_root,
|
|
1033
|
+
{
|
|
1034
|
+
"status": "blocked",
|
|
1035
|
+
"reason": "binary_still_missing_after_install",
|
|
1036
|
+
"test_command": test_command or "",
|
|
1037
|
+
"required_binary": missing_binary,
|
|
1038
|
+
"install_plan": plan,
|
|
1039
|
+
"missing_lsp_servers": missing_lsp,
|
|
1040
|
+
"unsupported_lsp_servers": unsupported_lsp,
|
|
1041
|
+
},
|
|
1042
|
+
)
|
|
1043
|
+
return False, f"Preflight still failed after install: '{missing_binary}' not found."
|
|
1044
|
+
still_missing_lsp = [
|
|
1045
|
+
cmd for cmd in missing_lsp if not command_exists_in_sandbox(project_root=project_root, binary=cmd)
|
|
1046
|
+
]
|
|
1047
|
+
if still_missing_lsp:
|
|
1048
|
+
write_sandbox_preflight(
|
|
1049
|
+
project_root,
|
|
1050
|
+
{
|
|
1051
|
+
"status": "blocked",
|
|
1052
|
+
"reason": "lsp_still_missing_after_install",
|
|
1053
|
+
"test_command": test_command or "",
|
|
1054
|
+
"required_binary": missing_binary,
|
|
1055
|
+
"install_plan": plan,
|
|
1056
|
+
"missing_lsp_servers": still_missing_lsp,
|
|
1057
|
+
"unsupported_lsp_servers": unsupported_lsp,
|
|
1058
|
+
},
|
|
1059
|
+
)
|
|
1060
|
+
names = ", ".join(required_lsp.get(cmd, cmd) for cmd in still_missing_lsp)
|
|
1061
|
+
return False, f"Preflight still failed after install: missing LSP server(s): {names}."
|
|
1062
|
+
write_sandbox_preflight(
|
|
1063
|
+
project_root,
|
|
1064
|
+
{
|
|
1065
|
+
"status": "passed",
|
|
1066
|
+
"reason": "requirements_found_after_install",
|
|
1067
|
+
"test_command": test_command or "",
|
|
1068
|
+
"required_binary": missing_binary,
|
|
1069
|
+
"install_plan": plan,
|
|
1070
|
+
"required_lsp_servers": sorted(required_lsp),
|
|
1071
|
+
"missing_lsp_servers": [],
|
|
1072
|
+
},
|
|
1073
|
+
)
|
|
1074
|
+
if missing_binary and missing_lsp:
|
|
1075
|
+
return True, (
|
|
1076
|
+
f"Preflight passed after install: found '{missing_binary}' and installed required LSP server(s)."
|
|
1077
|
+
)
|
|
1078
|
+
if missing_binary:
|
|
1079
|
+
return True, f"Preflight passed after install: found '{missing_binary}'."
|
|
1080
|
+
return True, "Preflight passed after install: required LSP server(s) installed."
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _sandbox_settings(project_config) -> dict:
|
|
1084
|
+
settings = dict(getattr(project_config, "settings", {}) or {})
|
|
1085
|
+
sandbox_cfg = settings.get("sandbox", {})
|
|
1086
|
+
return sandbox_cfg if isinstance(sandbox_cfg, dict) else {}
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def _sandbox_enabled(project_config) -> bool:
|
|
1090
|
+
# Sandbox is now mandatory for local review/code execution.
|
|
1091
|
+
return True
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def _sandbox_lifecycle(project_config) -> str:
|
|
1095
|
+
lifecycle = str(_sandbox_settings(project_config).get("lifecycle", "per_run") or "per_run").strip().lower()
|
|
1096
|
+
return lifecycle if lifecycle in {"per_project", "per_run"} else "per_run"
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def _ensure_sandbox_ready(
|
|
1100
|
+
*,
|
|
1101
|
+
project_root: Path,
|
|
1102
|
+
project_config,
|
|
1103
|
+
stage: str,
|
|
1104
|
+
auto_approve_install: bool = False,
|
|
1105
|
+
no_install: bool = False,
|
|
1106
|
+
) -> dict | None:
|
|
1107
|
+
"""Ensure sandbox lifecycle + preflight for stage execution."""
|
|
1108
|
+
sandbox_cfg = _sandbox_settings(project_config)
|
|
1109
|
+
lifecycle = _sandbox_lifecycle(project_config)
|
|
1110
|
+
ephemeral = lifecycle == "per_run" and stage in {"review", "code"}
|
|
1111
|
+
preferred_runtime = str(sandbox_cfg.get("runtime", "podman") or "podman")
|
|
1112
|
+
resolved_runtime, reason = resolve_runtime(preferred_runtime)
|
|
1113
|
+
if not resolved_runtime:
|
|
1114
|
+
console.print(f"[red]{stage}: sandbox runtime unavailable:[/red] {reason}")
|
|
1115
|
+
raise typer.Exit(code=1)
|
|
1116
|
+
resolved_image = str(sandbox_cfg.get("image") or SANDBOX_DEFAULT_IMAGE)
|
|
1117
|
+
workdir = str(sandbox_cfg.get("workdir", "/workspace") or "/workspace")
|
|
1118
|
+
run_container = None
|
|
1119
|
+
if ephemeral:
|
|
1120
|
+
run_container = f"{sandbox_name(project_root)}-run-{uuid.uuid4().hex[:8]}"
|
|
1121
|
+
ok, message, state = ensure_sandbox(
|
|
1122
|
+
project_root=project_root,
|
|
1123
|
+
runtime=resolved_runtime,
|
|
1124
|
+
image=resolved_image,
|
|
1125
|
+
rebuild=False,
|
|
1126
|
+
workdir=workdir,
|
|
1127
|
+
container_name=run_container,
|
|
1128
|
+
persist_state=not ephemeral,
|
|
1129
|
+
)
|
|
1130
|
+
if not ok:
|
|
1131
|
+
console.print(f"[red]{stage}: sandbox start failed:[/red] {message}")
|
|
1132
|
+
raise typer.Exit(code=1)
|
|
1133
|
+
console.print(
|
|
1134
|
+
f"[green]{stage}: sandbox ready.[/green] runtime={resolved_runtime} "
|
|
1135
|
+
f"container={state.get('container_name', '?')}"
|
|
1136
|
+
)
|
|
1137
|
+
settings = dict(getattr(project_config, "settings", {}) or {})
|
|
1138
|
+
preflight_ok, preflight_msg = _sandbox_preflight(
|
|
1139
|
+
project_root=project_root,
|
|
1140
|
+
config_settings=settings,
|
|
1141
|
+
auto_approve=auto_approve_install,
|
|
1142
|
+
no_install=no_install,
|
|
1143
|
+
)
|
|
1144
|
+
if not preflight_ok:
|
|
1145
|
+
console.print(f"[red]{stage}: {preflight_msg}[/red]")
|
|
1146
|
+
raise typer.Exit(code=1)
|
|
1147
|
+
console.print(f"[green]{stage}: {preflight_msg}[/green]")
|
|
1148
|
+
return {
|
|
1149
|
+
"ephemeral": ephemeral,
|
|
1150
|
+
"runtime": resolved_runtime,
|
|
1151
|
+
"container_name": state.get("container_name", ""),
|
|
1152
|
+
"stage": stage,
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def _teardown_sandbox_context(project_root: Path, ctx: dict | None) -> None:
|
|
1157
|
+
"""Clean up ephemeral per-run sandboxes."""
|
|
1158
|
+
if not ctx or not ctx.get("ephemeral"):
|
|
1159
|
+
return
|
|
1160
|
+
runtime = str(ctx.get("runtime") or "")
|
|
1161
|
+
name = str(ctx.get("container_name") or "")
|
|
1162
|
+
ok, msg = remove_sandbox_container(runtime=runtime, container_name=name)
|
|
1163
|
+
color = "green" if ok else "yellow"
|
|
1164
|
+
console.print(f"[{color}]sandbox cleanup[/{color}]: {msg}")
|
|
1165
|
+
# Defensive: clear project-level state if a per-project name was accidentally used.
|
|
1166
|
+
if name == sandbox_name(project_root):
|
|
1167
|
+
clear_sandbox_state(project_root)
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def start(
|
|
1171
|
+
rebuild: bool = typer.Option(False, "--rebuild", help="Recreate sandbox container from scratch."),
|
|
1172
|
+
runtime: str = typer.Option("podman", "--runtime", help="Sandbox runtime (R11.1: podman)."),
|
|
1173
|
+
image: str = typer.Option(
|
|
1174
|
+
SANDBOX_DEFAULT_IMAGE,
|
|
1175
|
+
"--image",
|
|
1176
|
+
help="Sandbox base image used to run local commands.",
|
|
1177
|
+
),
|
|
1178
|
+
yes: bool = typer.Option(False, "--yes", help="Auto-approve sandbox install plan."),
|
|
1179
|
+
no_install: bool = typer.Option(
|
|
1180
|
+
False, "--no-install", help="Run sandbox preflight only; do not install missing deps."
|
|
1181
|
+
),
|
|
1182
|
+
shell: bool | None = typer.Option(
|
|
1183
|
+
None,
|
|
1184
|
+
"--shell/--no-shell",
|
|
1185
|
+
help="Attach an interactive shell after sandbox is ready. "
|
|
1186
|
+
"Defaults to disabled; use --shell for debug access.",
|
|
1187
|
+
),
|
|
1188
|
+
) -> None:
|
|
1189
|
+
"""Start (or reuse) the local project sandbox."""
|
|
1190
|
+
_require_auth()
|
|
1191
|
+
project_root, _constitution, project_config = _load_project()
|
|
1192
|
+
register_project(project_root)
|
|
1193
|
+
|
|
1194
|
+
sandbox_cfg = _sandbox_settings(project_config)
|
|
1195
|
+
preferred_runtime = str(runtime or sandbox_cfg.get("runtime", "podman") or "podman")
|
|
1196
|
+
resolved_runtime, reason = resolve_runtime(preferred_runtime)
|
|
1197
|
+
if not resolved_runtime:
|
|
1198
|
+
installed, install_msg = _maybe_install_host_runtime(
|
|
1199
|
+
runtime=preferred_runtime,
|
|
1200
|
+
reason=f"Sandbox runtime unavailable: {reason}",
|
|
1201
|
+
auto_approve=yes,
|
|
1202
|
+
)
|
|
1203
|
+
if not installed:
|
|
1204
|
+
console.print(f"[red]Sandbox runtime unavailable:[/red] {install_msg}")
|
|
1205
|
+
raise typer.Exit(code=1)
|
|
1206
|
+
console.print(f"[green]{install_msg}[/green]")
|
|
1207
|
+
resolved_runtime, reason = resolve_runtime(preferred_runtime)
|
|
1208
|
+
if not resolved_runtime:
|
|
1209
|
+
console.print(
|
|
1210
|
+
f"[red]Sandbox runtime still unavailable after install:[/red] {reason}"
|
|
1211
|
+
)
|
|
1212
|
+
raise typer.Exit(code=1)
|
|
1213
|
+
|
|
1214
|
+
resolved_image = str(image or sandbox_cfg.get("image") or SANDBOX_DEFAULT_IMAGE)
|
|
1215
|
+
workdir = str(sandbox_cfg.get("workdir", "/workspace") or "/workspace")
|
|
1216
|
+
ok, message, state = ensure_sandbox(
|
|
1217
|
+
project_root=project_root,
|
|
1218
|
+
runtime=resolved_runtime,
|
|
1219
|
+
image=resolved_image,
|
|
1220
|
+
rebuild=rebuild,
|
|
1221
|
+
workdir=workdir,
|
|
1222
|
+
)
|
|
1223
|
+
if not ok:
|
|
1224
|
+
console.print(f"[red]Sandbox start failed:[/red] {message}")
|
|
1225
|
+
raise typer.Exit(code=1)
|
|
1226
|
+
console.print(
|
|
1227
|
+
f"[green]Sandbox ready.[/green] runtime={resolved_runtime} "
|
|
1228
|
+
f"container={state.get('container_name', '?')} image={resolved_image}"
|
|
1229
|
+
)
|
|
1230
|
+
settings = dict(getattr(project_config, "settings", {}) or {})
|
|
1231
|
+
preflight_ok, preflight_msg = _sandbox_preflight(
|
|
1232
|
+
project_root=project_root,
|
|
1233
|
+
config_settings=settings,
|
|
1234
|
+
auto_approve=yes,
|
|
1235
|
+
no_install=no_install,
|
|
1236
|
+
)
|
|
1237
|
+
if not preflight_ok:
|
|
1238
|
+
console.print(f"[red]{preflight_msg}[/red]")
|
|
1239
|
+
raise typer.Exit(code=1)
|
|
1240
|
+
console.print(f"[green]{preflight_msg}[/green]")
|
|
1241
|
+
|
|
1242
|
+
should_shell = bool(shell) if shell is not None else False
|
|
1243
|
+
if not should_shell:
|
|
1244
|
+
return
|
|
1245
|
+
if not sys.stdin.isatty():
|
|
1246
|
+
console.print("[yellow]Non-interactive terminal; cannot attach shell.[/yellow]")
|
|
1247
|
+
raise typer.Exit(code=1)
|
|
1248
|
+
shell_path = str(sandbox_cfg.get("shell", SANDBOX_DEFAULT_SHELL) or SANDBOX_DEFAULT_SHELL)
|
|
1249
|
+
attached, detail = attach_sandbox_shell(project_root=project_root, shell_path=shell_path)
|
|
1250
|
+
if not attached:
|
|
1251
|
+
console.print(f"[red]Sandbox shell failed:[/red] {detail}")
|
|
1252
|
+
raise typer.Exit(code=1)
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
def stop() -> None:
|
|
1256
|
+
"""Stop the local sandbox for this project."""
|
|
1257
|
+
_require_auth()
|
|
1258
|
+
try:
|
|
1259
|
+
project_root = Path.cwd()
|
|
1260
|
+
except OSError:
|
|
1261
|
+
console.print(
|
|
1262
|
+
"[red]Could not resolve the current project directory.[/red] "
|
|
1263
|
+
"Switch to a valid directory and retry."
|
|
1264
|
+
)
|
|
1265
|
+
raise typer.Exit(code=1) from None
|
|
1266
|
+
ok, message = stop_sandbox(project_root=project_root)
|
|
1267
|
+
if ok:
|
|
1268
|
+
console.print(f"[green]{message}[/green]")
|
|
1269
|
+
return
|
|
1270
|
+
console.print(f"[yellow]{message}[/yellow]")
|
|
1271
|
+
raise typer.Exit(code=1)
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def shell(
|
|
1275
|
+
shell_path: str = typer.Option(
|
|
1276
|
+
SANDBOX_DEFAULT_SHELL, "--shell-path", help="Shell executable inside sandbox."
|
|
1277
|
+
),
|
|
1278
|
+
) -> None:
|
|
1279
|
+
"""Attach an interactive shell to the current project's sandbox."""
|
|
1280
|
+
_require_auth()
|
|
1281
|
+
project_root, _constitution, _config = _load_project()
|
|
1282
|
+
state = read_sandbox_state(project_root)
|
|
1283
|
+
if not state:
|
|
1284
|
+
console.print("[yellow]No sandbox found. Run `refactor start` first.[/yellow]")
|
|
1285
|
+
raise typer.Exit(code=1)
|
|
1286
|
+
if not sys.stdin.isatty():
|
|
1287
|
+
console.print("[yellow]Non-interactive terminal; cannot attach shell.[/yellow]")
|
|
1288
|
+
raise typer.Exit(code=1)
|
|
1289
|
+
ok, message = attach_sandbox_shell(project_root=project_root, shell_path=shell_path)
|
|
1290
|
+
if ok:
|
|
1291
|
+
return
|
|
1292
|
+
console.print(f"[red]Sandbox shell failed:[/red] {message}")
|
|
1293
|
+
raise typer.Exit(code=1)
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
def review(
|
|
1297
|
+
target: str = typer.Argument(".", help="File, directory, or symbol to review."),
|
|
1298
|
+
provider: str | None = typer.Option(
|
|
1299
|
+
None,
|
|
1300
|
+
"--provider",
|
|
1301
|
+
help="Override provider for this run (takes precedence over env/constitution).",
|
|
1302
|
+
),
|
|
1303
|
+
mode: str | None = typer.Option(
|
|
1304
|
+
None,
|
|
1305
|
+
"--mode",
|
|
1306
|
+
help="Execution mode: auto | agentic | planner (default resolves from capability).",
|
|
1307
|
+
),
|
|
1308
|
+
force: bool = typer.Option(
|
|
1309
|
+
False,
|
|
1310
|
+
"--force",
|
|
1311
|
+
help="Run review even if unapplied patches exist from a prior code run.",
|
|
1312
|
+
),
|
|
1313
|
+
) -> None:
|
|
1314
|
+
"""Run review (findings only) and store the result. Requires a valid key."""
|
|
1315
|
+
_require_auth()
|
|
1316
|
+
project_root, constitution, project_config = _load_project()
|
|
1317
|
+
register_project(project_root, constitution_hash=constitution.content_hash)
|
|
1318
|
+
_maybe_build_graph(project_root, project_config)
|
|
1319
|
+
sandbox_ctx = None
|
|
1320
|
+
|
|
1321
|
+
try:
|
|
1322
|
+
cloud = _is_cloud_mode(constitution)
|
|
1323
|
+
if _sandbox_enabled(project_config):
|
|
1324
|
+
if cloud:
|
|
1325
|
+
console.print(
|
|
1326
|
+
"[dim]review: sandbox.enabled is ignored in cloud_managed mode.[/dim]"
|
|
1327
|
+
)
|
|
1328
|
+
else:
|
|
1329
|
+
sandbox_ctx = _ensure_sandbox_ready(
|
|
1330
|
+
project_root=project_root,
|
|
1331
|
+
project_config=project_config,
|
|
1332
|
+
stage="review",
|
|
1333
|
+
auto_approve_install=False,
|
|
1334
|
+
no_install=False,
|
|
1335
|
+
)
|
|
1336
|
+
if cloud:
|
|
1337
|
+
artifact = _cloud_inference(
|
|
1338
|
+
stage="review",
|
|
1339
|
+
project_root=project_root,
|
|
1340
|
+
constitution=constitution,
|
|
1341
|
+
target=target,
|
|
1342
|
+
model_hint=provider,
|
|
1343
|
+
)
|
|
1344
|
+
else:
|
|
1345
|
+
selected_provider = get_provider(
|
|
1346
|
+
constitution, project_root=project_root, provider_name=provider
|
|
1347
|
+
)
|
|
1348
|
+
verbose_steps = bool(constitution.get_setting("agent_verbose_steps", True))
|
|
1349
|
+
with console.status("[cyan]review[/cyan] preparing...", spinner="dots") as status:
|
|
1350
|
+
progress_cb = _agent_progress_logger(
|
|
1351
|
+
"review", status=status, verbose_steps=verbose_steps
|
|
1352
|
+
)
|
|
1353
|
+
artifact = run_with_mode(
|
|
1354
|
+
project_root=project_root,
|
|
1355
|
+
constitution=constitution,
|
|
1356
|
+
provider=selected_provider,
|
|
1357
|
+
target=target,
|
|
1358
|
+
do_refactor=False,
|
|
1359
|
+
cli_mode=mode,
|
|
1360
|
+
progress_cb=progress_cb,
|
|
1361
|
+
step_limit_cb=_step_limit_prompt("review"),
|
|
1362
|
+
)
|
|
1363
|
+
except Exception as exc: # provider/transport errors should not crash the CLI
|
|
1364
|
+
console.print(f"[red]Review failed:[/red] {exc}")
|
|
1365
|
+
raise typer.Exit(code=1) from exc
|
|
1366
|
+
finally:
|
|
1367
|
+
_teardown_sandbox_context(project_root, sandbox_ctx)
|
|
1368
|
+
|
|
1369
|
+
run_store = create_run(project_root, run_id=artifact.run_id)
|
|
1370
|
+
run_store.write_artifact(artifact, target=target, status="completed")
|
|
1371
|
+
|
|
1372
|
+
_print_findings(artifact.findings)
|
|
1373
|
+
|
|
1374
|
+
# A fresh review supersedes prior refactor requests: replace them with one
|
|
1375
|
+
# RR per finding (central-only; nothing is written into the project tree).
|
|
1376
|
+
clear_requests(project_root)
|
|
1377
|
+
requests = create_requests(project_root, artifact.findings, run_id=artifact.run_id)
|
|
1378
|
+
if requests:
|
|
1379
|
+
console.print(
|
|
1380
|
+
f"\n[bold]Created {len(requests)} refactor request(s).[/bold] "
|
|
1381
|
+
"[dim]Run `refactor code` to work through them one by one.[/dim]"
|
|
1382
|
+
)
|
|
1383
|
+
|
|
1384
|
+
gate_color = "green" if artifact.gate.status == "passed" else "red"
|
|
1385
|
+
mode_note = f" mode={artifact.execution_mode}" if artifact.execution_mode else ""
|
|
1386
|
+
console.print(
|
|
1387
|
+
f"\n[bold]gate[/bold]: [{gate_color}]{artifact.gate.status}[/{gate_color}] "
|
|
1388
|
+
f"(high findings: {artifact.gate.high_severity_findings}) "
|
|
1389
|
+
f"[dim]model={artifact.model_id}{mode_note} run={run_store.run_id}[/dim]"
|
|
1390
|
+
)
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def code(
|
|
1394
|
+
target: str = typer.Argument(".", help="File, directory, or symbol to refactor."),
|
|
1395
|
+
apply: bool = typer.Option(False, "--apply", help="Apply approved patches in place."),
|
|
1396
|
+
force: bool = typer.Option(False, "--force", help="Apply even if the quality gate blocks."),
|
|
1397
|
+
provider: str | None = typer.Option(
|
|
1398
|
+
None,
|
|
1399
|
+
"--provider",
|
|
1400
|
+
help="Override provider for this run (takes precedence over env/constitution).",
|
|
1401
|
+
),
|
|
1402
|
+
mode: str | None = typer.Option(
|
|
1403
|
+
None,
|
|
1404
|
+
"--mode",
|
|
1405
|
+
help="Execution mode: auto | agentic | planner (default resolves from capability).",
|
|
1406
|
+
),
|
|
1407
|
+
auto_approve: bool = typer.Option(
|
|
1408
|
+
False,
|
|
1409
|
+
"--auto-approve",
|
|
1410
|
+
help="Skip interactive issue approvals in agentic code mode.",
|
|
1411
|
+
),
|
|
1412
|
+
) -> None:
|
|
1413
|
+
"""Work through refactor requests one by one, applying & verifying each.
|
|
1414
|
+
|
|
1415
|
+
If no pending refactor requests exist, a review is run first to create them,
|
|
1416
|
+
then code proceeds automatically with the first request. Requires a valid key.
|
|
1417
|
+
"""
|
|
1418
|
+
_require_auth()
|
|
1419
|
+
project_root, constitution, project_config = _load_project()
|
|
1420
|
+
register_project(project_root, constitution_hash=constitution.content_hash)
|
|
1421
|
+
_maybe_build_graph(project_root, project_config)
|
|
1422
|
+
sandbox_ctx = None
|
|
1423
|
+
|
|
1424
|
+
try:
|
|
1425
|
+
cloud = _is_cloud_mode(constitution)
|
|
1426
|
+
sandbox_on = _sandbox_enabled(project_config)
|
|
1427
|
+
if _sandbox_enabled(project_config):
|
|
1428
|
+
if cloud:
|
|
1429
|
+
console.print(
|
|
1430
|
+
"[dim]code: sandbox.enabled is ignored in cloud_managed mode.[/dim]"
|
|
1431
|
+
)
|
|
1432
|
+
else:
|
|
1433
|
+
sandbox_ctx = _ensure_sandbox_ready(
|
|
1434
|
+
project_root=project_root,
|
|
1435
|
+
project_config=project_config,
|
|
1436
|
+
stage="code",
|
|
1437
|
+
auto_approve_install=auto_approve,
|
|
1438
|
+
no_install=False,
|
|
1439
|
+
)
|
|
1440
|
+
selected_provider = None
|
|
1441
|
+
if not cloud:
|
|
1442
|
+
selected_provider = get_provider(
|
|
1443
|
+
constitution, project_root=project_root, provider_name=provider
|
|
1444
|
+
)
|
|
1445
|
+
|
|
1446
|
+
# If no pending refactor requests exist, run review first to create them.
|
|
1447
|
+
requests = pending_requests(project_root)
|
|
1448
|
+
if not requests:
|
|
1449
|
+
console.print("[dim]code: no pending refactor requests; running review first...[/dim]")
|
|
1450
|
+
if cloud:
|
|
1451
|
+
review_artifact = _cloud_inference(
|
|
1452
|
+
stage="review",
|
|
1453
|
+
project_root=project_root,
|
|
1454
|
+
constitution=constitution,
|
|
1455
|
+
target=target,
|
|
1456
|
+
model_hint=provider,
|
|
1457
|
+
)
|
|
1458
|
+
else:
|
|
1459
|
+
with console.status("[cyan]review[/cyan] analyzing...", spinner="dots") as status:
|
|
1460
|
+
review_progress = _agent_progress_logger(
|
|
1461
|
+
"review", status=status, verbose_steps=True
|
|
1462
|
+
)
|
|
1463
|
+
review_artifact = run_with_mode(
|
|
1464
|
+
project_root=project_root,
|
|
1465
|
+
constitution=constitution,
|
|
1466
|
+
provider=selected_provider,
|
|
1467
|
+
target=target,
|
|
1468
|
+
do_refactor=False,
|
|
1469
|
+
cli_mode=mode,
|
|
1470
|
+
progress_cb=review_progress,
|
|
1471
|
+
step_limit_cb=_step_limit_prompt("review"),
|
|
1472
|
+
)
|
|
1473
|
+
review_store = create_run(project_root, run_id=review_artifact.run_id)
|
|
1474
|
+
review_store.write_artifact(review_artifact, target=target, status="completed")
|
|
1475
|
+
_print_findings(review_artifact.findings)
|
|
1476
|
+
clear_requests(project_root)
|
|
1477
|
+
requests = create_requests(
|
|
1478
|
+
project_root, review_artifact.findings, run_id=review_artifact.run_id
|
|
1479
|
+
)
|
|
1480
|
+
console.print(f"[bold]Created {len(requests)} refactor request(s).[/bold]")
|
|
1481
|
+
|
|
1482
|
+
if not requests:
|
|
1483
|
+
console.print("[green]No issues to refactor.[/green]")
|
|
1484
|
+
return
|
|
1485
|
+
|
|
1486
|
+
if cloud:
|
|
1487
|
+
console.print(
|
|
1488
|
+
f"[yellow]Created {len(requests)} refactor request(s).[/yellow] "
|
|
1489
|
+
"The interactive apply-and-verify loop runs against a local provider; "
|
|
1490
|
+
"configure a local provider/key to work through them with `refactor code`."
|
|
1491
|
+
)
|
|
1492
|
+
return
|
|
1493
|
+
|
|
1494
|
+
with console.status("[cyan]code[/cyan] running...", spinner="dots") as status:
|
|
1495
|
+
artifact = run_refactor_requests(
|
|
1496
|
+
project_root=project_root,
|
|
1497
|
+
constitution=constitution,
|
|
1498
|
+
provider=selected_provider,
|
|
1499
|
+
requests=requests,
|
|
1500
|
+
config=dict(getattr(project_config, "settings", {}) or {})
|
|
1501
|
+
| ({"sandbox": {"enabled": True}} if sandbox_on else {})
|
|
1502
|
+
or dict(getattr(constitution, "settings", {}) or {}),
|
|
1503
|
+
progress_cb=_rr_progress_logger(status=status),
|
|
1504
|
+
approval_cb=_rr_approval_prompt(auto_approve=auto_approve),
|
|
1505
|
+
)
|
|
1506
|
+
except Exception as exc:
|
|
1507
|
+
console.print(f"[red]Refactor failed:[/red] {exc}")
|
|
1508
|
+
raise typer.Exit(code=1) from exc
|
|
1509
|
+
finally:
|
|
1510
|
+
_teardown_sandbox_context(project_root, sandbox_ctx)
|
|
1511
|
+
|
|
1512
|
+
_print_rr_summary(artifact)
|
|
1513
|
+
for note in getattr(artifact, "notes", []) or []:
|
|
1514
|
+
console.print(f"[yellow]note:[/yellow] {note}")
|
|
1515
|
+
gate_color = "green" if artifact.gate.status == "passed" else "red"
|
|
1516
|
+
console.print(
|
|
1517
|
+
f"\n[bold]result[/bold]: [{gate_color}]{artifact.gate.status}[/{gate_color}] "
|
|
1518
|
+
f"[dim]model={artifact.model_id} mode={artifact.execution_mode} run={artifact.run_id}[/dim]"
|
|
1519
|
+
)
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
def requests() -> None:
|
|
1523
|
+
"""List refactor requests and their status for this project."""
|
|
1524
|
+
_require_auth()
|
|
1525
|
+
project_root, _constitution, _config = _load_project()
|
|
1526
|
+
rrs = list_requests(project_root)
|
|
1527
|
+
if not rrs:
|
|
1528
|
+
console.print("[dim]No refactor requests. Run `refactor review` to create them.[/dim]")
|
|
1529
|
+
return
|
|
1530
|
+
table = Table(title="Refactor requests", show_lines=False)
|
|
1531
|
+
table.add_column("issue")
|
|
1532
|
+
table.add_column("severity", no_wrap=True)
|
|
1533
|
+
table.add_column("category")
|
|
1534
|
+
table.add_column("status", no_wrap=True)
|
|
1535
|
+
table.add_column("attempts", no_wrap=True)
|
|
1536
|
+
color = {"applied": "green", "denied": "yellow", "failed": "red", "pending": "cyan", "in_progress": "cyan"}
|
|
1537
|
+
for rr in rrs:
|
|
1538
|
+
table.add_row(
|
|
1539
|
+
rr.location,
|
|
1540
|
+
rr.severity,
|
|
1541
|
+
rr.category,
|
|
1542
|
+
f"[{color.get(rr.status, 'white')}]{rr.status}[/{color.get(rr.status, 'white')}]",
|
|
1543
|
+
str(rr.attempt_count),
|
|
1544
|
+
)
|
|
1545
|
+
console.print(table)
|
|
1546
|
+
|
|
1547
|
+
|
|
1548
|
+
def apply(
|
|
1549
|
+
force: bool = typer.Option(False, "--force", help="Apply even if the quality gate blocks."),
|
|
1550
|
+
) -> None:
|
|
1551
|
+
"""Apply the latest run's patches in place (atomic, with backup). Requires a key."""
|
|
1552
|
+
_require_auth()
|
|
1553
|
+
project_root, constitution, project_config = _load_project()
|
|
1554
|
+
run_dir = head_run_dir(project_root)
|
|
1555
|
+
if not run_dir:
|
|
1556
|
+
console.print("[yellow]No runs yet.[/yellow] Run `refactor code` first.")
|
|
1557
|
+
raise typer.Exit(code=1)
|
|
1558
|
+
|
|
1559
|
+
patch_files = sorted((run_dir / "patches").glob("*.diff")) if (run_dir / "patches").is_dir() else []
|
|
1560
|
+
if not patch_files:
|
|
1561
|
+
console.print("[dim]No proposed patches in the latest run.[/dim]")
|
|
1562
|
+
return
|
|
1563
|
+
|
|
1564
|
+
report = read_report(run_dir) or {}
|
|
1565
|
+
gate_status = (report.get("gate") or {}).get("status", "unknown")
|
|
1566
|
+
gate_mode = str(constitution.get_setting("gate_mode", "strict") or "strict")
|
|
1567
|
+
diffs = [p.read_text(encoding="utf-8") for p in patch_files]
|
|
1568
|
+
_apply_diffs(project_root, run_dir, diffs, gate_status, gate_mode, force, project_config)
|
|
1569
|
+
|
|
1570
|
+
|
|
1571
|
+
def revert() -> None:
|
|
1572
|
+
"""Restore files modified by the latest applied run from its backup."""
|
|
1573
|
+
project_root, _, _ = _load_project()
|
|
1574
|
+
run_dir = head_run_dir(project_root)
|
|
1575
|
+
if not run_dir:
|
|
1576
|
+
console.print("[yellow]No runs yet.[/yellow]")
|
|
1577
|
+
raise typer.Exit(code=1)
|
|
1578
|
+
try:
|
|
1579
|
+
restored = revert_from_backup(project_root, run_dir)
|
|
1580
|
+
except ApplyError as exc:
|
|
1581
|
+
console.print(f"[yellow]Nothing to revert:[/yellow] {exc}")
|
|
1582
|
+
raise typer.Exit(code=1) from exc
|
|
1583
|
+
console.print(f"[green]Reverted[/green] {len(restored)} file(s): {', '.join(restored) or '(none)'}")
|
|
1584
|
+
|
|
1585
|
+
|
|
1586
|
+
def _apply_diffs(project_root, run_dir, diffs, gate_status, gate_mode, force, config=None) -> None:
|
|
1587
|
+
if not diffs:
|
|
1588
|
+
console.print("[dim]No patches to apply.[/dim]")
|
|
1589
|
+
return
|
|
1590
|
+
if gate_mode == "strict" and gate_status != "passed" and not force:
|
|
1591
|
+
console.print(
|
|
1592
|
+
f"[red]Apply blocked by quality gate[/red] (status={gate_status}). "
|
|
1593
|
+
"Resolve high-severity findings or re-run with --force."
|
|
1594
|
+
)
|
|
1595
|
+
raise typer.Exit(code=1)
|
|
1596
|
+
|
|
1597
|
+
try:
|
|
1598
|
+
changes = compute_file_changes(project_root, diffs)
|
|
1599
|
+
except ApplyError as exc:
|
|
1600
|
+
console.print(f"[red]Apply failed:[/red] {exc}")
|
|
1601
|
+
raise typer.Exit(code=1) from exc
|
|
1602
|
+
|
|
1603
|
+
settings = dict(getattr(config, "settings", {}) or {}) if config is not None else {}
|
|
1604
|
+
if _verification_enabled(settings):
|
|
1605
|
+
_apply_with_verification(project_root, run_dir, changes, settings)
|
|
1606
|
+
return
|
|
1607
|
+
|
|
1608
|
+
try:
|
|
1609
|
+
written = apply_changes(project_root, run_dir, changes)
|
|
1610
|
+
except ApplyError as exc:
|
|
1611
|
+
console.print(f"[red]Apply failed:[/red] {exc}")
|
|
1612
|
+
raise typer.Exit(code=1) from exc
|
|
1613
|
+
console.print(
|
|
1614
|
+
f"[green]Applied[/green] {len(written)} file(s) in place: {', '.join(written)}. "
|
|
1615
|
+
"Use `refactor revert` to undo."
|
|
1616
|
+
)
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
def _latest_unapplied_patch_run(project_root: Path) -> dict | None:
|
|
1620
|
+
"""Return newest run with unapplied patches, if any.
|
|
1621
|
+
|
|
1622
|
+
A run is considered pending when it has patch files and has not been marked
|
|
1623
|
+
as applied in its `applied.json` marker.
|
|
1624
|
+
"""
|
|
1625
|
+
for run_dir in list_runs(project_root):
|
|
1626
|
+
patch_files = sorted((run_dir / "patches").glob("*.diff")) if (run_dir / "patches").is_dir() else []
|
|
1627
|
+
if not patch_files:
|
|
1628
|
+
continue
|
|
1629
|
+
marker = read_marker(run_dir)
|
|
1630
|
+
if marker and marker.get("status") == "applied":
|
|
1631
|
+
continue
|
|
1632
|
+
report = read_report(run_dir) or {}
|
|
1633
|
+
return {
|
|
1634
|
+
"run_id": report.get("run_id", run_dir.name),
|
|
1635
|
+
"run_dir": run_dir,
|
|
1636
|
+
"patch_count": len(patch_files),
|
|
1637
|
+
}
|
|
1638
|
+
return None
|
|
1639
|
+
|
|
1640
|
+
|
|
1641
|
+
def _verification_enabled(settings: dict) -> bool:
|
|
1642
|
+
verification = settings.get("verification")
|
|
1643
|
+
if not isinstance(verification, dict):
|
|
1644
|
+
return False
|
|
1645
|
+
return bool(
|
|
1646
|
+
verification.get("syntax", True)
|
|
1647
|
+
or verification.get("semantic", "auto") not in (False, "off")
|
|
1648
|
+
or (verification.get("tests", {}) or {}).get("command")
|
|
1649
|
+
)
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
def _apply_with_verification(project_root, run_dir, changes, settings) -> None:
|
|
1653
|
+
try:
|
|
1654
|
+
from refactor_core.codeintel.verification import apply_and_verify
|
|
1655
|
+
except Exception:
|
|
1656
|
+
# codeintel unavailable: fall back to plain apply.
|
|
1657
|
+
written = apply_changes(project_root, run_dir, changes)
|
|
1658
|
+
console.print(f"[green]Applied[/green] {len(written)} file(s) (verification unavailable).")
|
|
1659
|
+
return
|
|
1660
|
+
|
|
1661
|
+
try:
|
|
1662
|
+
result = apply_and_verify(project_root, run_dir, changes, config=settings)
|
|
1663
|
+
except ApplyError as exc:
|
|
1664
|
+
console.print(f"[red]Apply failed:[/red] {exc}")
|
|
1665
|
+
raise typer.Exit(code=1) from exc
|
|
1666
|
+
|
|
1667
|
+
for rung in result.rungs:
|
|
1668
|
+
if not rung.ran:
|
|
1669
|
+
continue
|
|
1670
|
+
color = "green" if rung.passed else "red"
|
|
1671
|
+
console.print(f" [{color}]verify:{rung.name}[/{color}] {'ok' if rung.passed else 'failed'}")
|
|
1672
|
+
for detail in rung.details[:5]:
|
|
1673
|
+
console.print(f" [dim]{detail}[/dim]")
|
|
1674
|
+
|
|
1675
|
+
if result.passed:
|
|
1676
|
+
console.print(
|
|
1677
|
+
f"[green]Applied + verified[/green] {len(result.changed_files)} file(s): "
|
|
1678
|
+
f"{', '.join(result.changed_files)}. Use `refactor revert` to undo."
|
|
1679
|
+
)
|
|
1680
|
+
else:
|
|
1681
|
+
rolled = "rolled back" if result.rolled_back else "NOT rolled back (manual revert needed)"
|
|
1682
|
+
console.print(f"[red]Verification failed[/red]; changes {rolled}.")
|
|
1683
|
+
raise typer.Exit(code=1)
|
|
1684
|
+
|
|
1685
|
+
|
|
1686
|
+
def status() -> None:
|
|
1687
|
+
"""Show the latest run and its gate result."""
|
|
1688
|
+
project_root, _, _ = _load_project()
|
|
1689
|
+
run_dir = head_run_dir(project_root)
|
|
1690
|
+
if not run_dir:
|
|
1691
|
+
console.print(f"Project: {project_root}")
|
|
1692
|
+
console.print("[dim]No runs yet. Run `refactor review` to create one.[/dim]")
|
|
1693
|
+
return
|
|
1694
|
+
|
|
1695
|
+
report = read_report(run_dir) or {}
|
|
1696
|
+
console.print(f"Project: {project_root}")
|
|
1697
|
+
console.print(f"Latest run: [bold]{report.get('run_id', run_dir.name)}[/bold] ({report.get('created_at', '?')})")
|
|
1698
|
+
console.print(f"Target: {report.get('target', '?')} | Summary: {report.get('summary', '')}")
|
|
1699
|
+
gate = report.get("gate", {})
|
|
1700
|
+
gate_status = gate.get("status", "unknown")
|
|
1701
|
+
gate_color = "green" if gate_status == "passed" else "red"
|
|
1702
|
+
console.print(
|
|
1703
|
+
f"Findings: {report.get('findings_count', 0)} | "
|
|
1704
|
+
f"Gate: [{gate_color}]{gate_status}[/{gate_color}]"
|
|
1705
|
+
)
|
|
1706
|
+
if report.get("rule_source"):
|
|
1707
|
+
console.print(
|
|
1708
|
+
"Rule trace: "
|
|
1709
|
+
f"source={report.get('rule_source')} "
|
|
1710
|
+
f"pattern={report.get('rule_pattern', '')} "
|
|
1711
|
+
f"hash={str(report.get('rule_hash', ''))[:12]}"
|
|
1712
|
+
)
|
|
1713
|
+
|
|
1714
|
+
|
|
1715
|
+
def _is_git_repo(project_root: Path) -> bool:
|
|
1716
|
+
proc = subprocess.run(
|
|
1717
|
+
["git", "-C", str(project_root), "rev-parse", "--is-inside-work-tree"],
|
|
1718
|
+
capture_output=True,
|
|
1719
|
+
text=True,
|
|
1720
|
+
)
|
|
1721
|
+
return proc.returncode == 0 and proc.stdout.strip().lower() == "true"
|
|
1722
|
+
|
|
1723
|
+
|
|
1724
|
+
def _git_diff_text(project_root: Path, *, staged: bool) -> str:
|
|
1725
|
+
cmd = ["git", "-C", str(project_root), "diff"]
|
|
1726
|
+
if staged:
|
|
1727
|
+
cmd.append("--staged")
|
|
1728
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
1729
|
+
if proc.returncode != 0:
|
|
1730
|
+
return ""
|
|
1731
|
+
return proc.stdout or ""
|
|
1732
|
+
|
|
1733
|
+
|
|
1734
|
+
def diff() -> None:
|
|
1735
|
+
"""Show proposed diffs, or applied RR diffs/summary for latest run."""
|
|
1736
|
+
project_root, _, _ = _load_project()
|
|
1737
|
+
run_dir = head_run_dir(project_root)
|
|
1738
|
+
if not run_dir:
|
|
1739
|
+
console.print("[dim]No runs yet.[/dim]")
|
|
1740
|
+
return
|
|
1741
|
+
report = read_report(run_dir) or {}
|
|
1742
|
+
patches = sorted((run_dir / "patches").glob("*.diff")) if (run_dir / "patches").is_dir() else []
|
|
1743
|
+
if patches:
|
|
1744
|
+
for patch in patches:
|
|
1745
|
+
console.print(f"[bold]{patch.name}[/bold]")
|
|
1746
|
+
console.print(patch.read_text(encoding="utf-8"))
|
|
1747
|
+
return
|
|
1748
|
+
|
|
1749
|
+
mode = str(report.get("execution_mode", "") or "")
|
|
1750
|
+
trace = report.get("agent_trace_summary", {})
|
|
1751
|
+
if not isinstance(trace, dict):
|
|
1752
|
+
trace = {}
|
|
1753
|
+
applied_count = int(trace.get("applied", 0) or 0)
|
|
1754
|
+
if mode == "rr" and applied_count > 0:
|
|
1755
|
+
if _is_git_repo(project_root):
|
|
1756
|
+
unstaged = _git_diff_text(project_root, staged=False)
|
|
1757
|
+
staged = _git_diff_text(project_root, staged=True)
|
|
1758
|
+
if unstaged.strip() or staged.strip():
|
|
1759
|
+
console.print("[bold]Applied changes (git working tree)[/bold]")
|
|
1760
|
+
console.print("\n[bold]Unstaged changes[/bold]")
|
|
1761
|
+
console.print(unstaged.strip() or "[dim](none)[/dim]")
|
|
1762
|
+
console.print("\n[bold]Staged changes[/bold]")
|
|
1763
|
+
console.print(staged.strip() or "[dim](none)[/dim]")
|
|
1764
|
+
return
|
|
1765
|
+
outcomes = trace.get("outcomes", [])
|
|
1766
|
+
if not isinstance(outcomes, list):
|
|
1767
|
+
outcomes = []
|
|
1768
|
+
locations = []
|
|
1769
|
+
for item in outcomes:
|
|
1770
|
+
if not isinstance(item, dict):
|
|
1771
|
+
continue
|
|
1772
|
+
if str(item.get("outcome", "")) != "applied":
|
|
1773
|
+
continue
|
|
1774
|
+
location = str(item.get("location", "") or "").strip()
|
|
1775
|
+
if location:
|
|
1776
|
+
locations.append(location)
|
|
1777
|
+
if locations:
|
|
1778
|
+
console.print("[bold]Applied changes (RR run)[/bold]")
|
|
1779
|
+
for location in locations:
|
|
1780
|
+
console.print(f" - {location}")
|
|
1781
|
+
console.print("[dim]Tip: use `git diff` for line-by-line working-tree details.[/dim]")
|
|
1782
|
+
return
|
|
1783
|
+
|
|
1784
|
+
console.print(
|
|
1785
|
+
"[dim]No proposed changes in the latest run. "
|
|
1786
|
+
"Run `refactor code .` to generate patches, then `refactor diff` again.[/dim]"
|
|
1787
|
+
)
|
|
1788
|
+
|
|
1789
|
+
|
|
1790
|
+
def gc(
|
|
1791
|
+
keep: int = typer.Option(20, "--keep", help="Number of most recent runs to retain."),
|
|
1792
|
+
) -> None:
|
|
1793
|
+
"""Prune old runs from the central store (keeps HEAD and the newest --keep)."""
|
|
1794
|
+
project_root, _, _ = _load_project()
|
|
1795
|
+
before = len(list_runs(project_root))
|
|
1796
|
+
removed = prune_runs(project_root, keep=keep)
|
|
1797
|
+
console.print(
|
|
1798
|
+
f"[green]Pruned[/green] {len(removed)} run(s); kept {before - len(removed)} "
|
|
1799
|
+
f"under {project_store_dir(project_root)}."
|
|
1800
|
+
)
|
|
1801
|
+
|
|
1802
|
+
|
|
1803
|
+
def check(
|
|
1804
|
+
strict: bool = typer.Option(False, "--strict", help="Exit non-zero if issues are found (for CI)."),
|
|
1805
|
+
) -> None:
|
|
1806
|
+
"""Check the constitution for committed secrets (developer/provider keys)."""
|
|
1807
|
+
files = find_project_files()
|
|
1808
|
+
if not files:
|
|
1809
|
+
console.print(f"[yellow]Missing {CONSTITUTION_FILENAME} or {CONFIG_FILENAME}.[/yellow]")
|
|
1810
|
+
raise typer.Exit(code=1 if strict else 0)
|
|
1811
|
+
consti_path, config_path = files
|
|
1812
|
+
constitution = load_constitution(consti_path)
|
|
1813
|
+
config = load_config(config_path)
|
|
1814
|
+
findings = raw_secret_matches(constitution) + raw_secret_matches_config(config)
|
|
1815
|
+
if findings:
|
|
1816
|
+
kinds = ", ".join(sorted({kind for kind, _ in findings}))
|
|
1817
|
+
console.print(
|
|
1818
|
+
f"[red]Found literal secret-like value(s)[/red] ({kinds}) in "
|
|
1819
|
+
f"{consti_path} or {config_path}. Replace secrets with `${{ENV_VAR}}` references."
|
|
1820
|
+
)
|
|
1821
|
+
raise typer.Exit(code=1 if strict else 0)
|
|
1822
|
+
console.print("[green]OK[/green]: no committed secret-like literals detected.")
|
|
1823
|
+
|
|
1824
|
+
|
|
1825
|
+
def _mask_secret(value: str) -> str:
|
|
1826
|
+
value = value.strip()
|
|
1827
|
+
if len(value) <= 8:
|
|
1828
|
+
return "set (****)"
|
|
1829
|
+
return f"set ({value[:4]}…{value[-4:]})"
|
|
1830
|
+
|
|
1831
|
+
|
|
1832
|
+
def _print_detected_credentials(provider_name: str) -> None:
|
|
1833
|
+
"""Show masked presence of resolved provider credentials (never the value)."""
|
|
1834
|
+
for req in _REQUIRED_ENV_BY_PROVIDER.get(provider_name, []):
|
|
1835
|
+
names = req if isinstance(req, tuple) else (str(req),)
|
|
1836
|
+
for name in names:
|
|
1837
|
+
raw = os.environ.get(name, "").strip()
|
|
1838
|
+
if raw:
|
|
1839
|
+
console.print(f" [dim]{name}[/dim]: {_mask_secret(raw)}")
|
|
1840
|
+
break
|
|
1841
|
+
|
|
1842
|
+
|
|
1843
|
+
def _missing_requirements(provider_name: str) -> list[str]:
|
|
1844
|
+
requirements = _REQUIRED_ENV_BY_PROVIDER.get(provider_name, [])
|
|
1845
|
+
missing: list[str] = []
|
|
1846
|
+
for req in requirements:
|
|
1847
|
+
if isinstance(req, tuple):
|
|
1848
|
+
if not any(os.environ.get(name, "").strip() for name in req):
|
|
1849
|
+
missing.append(" or ".join(req))
|
|
1850
|
+
else:
|
|
1851
|
+
if not os.environ.get(str(req), "").strip():
|
|
1852
|
+
missing.append(str(req))
|
|
1853
|
+
return missing
|
|
1854
|
+
|
|
1855
|
+
|
|
1856
|
+
def doctor(
|
|
1857
|
+
provider: str | None = typer.Option(
|
|
1858
|
+
None,
|
|
1859
|
+
"--provider",
|
|
1860
|
+
help="Provider override for diagnostics (default resolves from env/constitution).",
|
|
1861
|
+
),
|
|
1862
|
+
skip_ping: bool = typer.Option(
|
|
1863
|
+
False,
|
|
1864
|
+
"--skip-ping",
|
|
1865
|
+
help="Only validate configuration; do not call provider endpoint.",
|
|
1866
|
+
),
|
|
1867
|
+
sandbox: bool = typer.Option(
|
|
1868
|
+
False,
|
|
1869
|
+
"--sandbox",
|
|
1870
|
+
help="Show local sandbox diagnostics and persisted preflight/install history.",
|
|
1871
|
+
),
|
|
1872
|
+
) -> None:
|
|
1873
|
+
"""Preflight provider diagnostics: auth status, env checks, and live provider ping."""
|
|
1874
|
+
files = find_project_files()
|
|
1875
|
+
if not files:
|
|
1876
|
+
console.print(
|
|
1877
|
+
f"[yellow]Missing {CONSTITUTION_FILENAME} or {CONFIG_FILENAME}.[/yellow] "
|
|
1878
|
+
"Run `refactor init` first."
|
|
1879
|
+
)
|
|
1880
|
+
raise typer.Exit(code=1)
|
|
1881
|
+
consti_path, _ = files
|
|
1882
|
+
constitution = load_constitution(consti_path)
|
|
1883
|
+
project_config = load_config(consti_path.parent / CONFIG_FILENAME)
|
|
1884
|
+
project_root = consti_path.parent
|
|
1885
|
+
|
|
1886
|
+
if sandbox:
|
|
1887
|
+
_report_sandbox(project_root, project_config)
|
|
1888
|
+
return
|
|
1889
|
+
|
|
1890
|
+
mode = str(constitution.get_setting("mode", "local") or "local")
|
|
1891
|
+
provider_name = resolve_provider_name(constitution, provider_name=provider)
|
|
1892
|
+
|
|
1893
|
+
console.print(f"[bold]project[/bold]: {project_root}")
|
|
1894
|
+
console.print(f"[bold]mode[/bold]: {mode}")
|
|
1895
|
+
console.print(f"[bold]provider[/bold]: {provider_name}")
|
|
1896
|
+
console.print(f"[bold]supported[/bold]: {', '.join(supported_providers())}")
|
|
1897
|
+
|
|
1898
|
+
try:
|
|
1899
|
+
auth_ctx = ensure_authenticated(project_root)
|
|
1900
|
+
cache_note = " (cached)" if auth_ctx.cached else ""
|
|
1901
|
+
console.print(f"[green]developer key[/green]: OK account={auth_ctx.account_id}{cache_note}")
|
|
1902
|
+
except AuthError as exc:
|
|
1903
|
+
console.print(f"[yellow]developer key[/yellow]: {exc}")
|
|
1904
|
+
|
|
1905
|
+
missing = _missing_requirements(provider_name)
|
|
1906
|
+
if missing:
|
|
1907
|
+
console.print("[red]provider env[/red]: missing required values:")
|
|
1908
|
+
for item in missing:
|
|
1909
|
+
console.print(f" - {item}")
|
|
1910
|
+
raise typer.Exit(code=1)
|
|
1911
|
+
console.print("[green]provider env[/green]: OK")
|
|
1912
|
+
_print_detected_credentials(provider_name)
|
|
1913
|
+
|
|
1914
|
+
try:
|
|
1915
|
+
selected = get_provider(constitution, project_root=project_root, provider_name=provider)
|
|
1916
|
+
except Exception as exc:
|
|
1917
|
+
console.print(f"[red]provider resolution failed[/red]: {exc}")
|
|
1918
|
+
raise typer.Exit(code=1) from exc
|
|
1919
|
+
|
|
1920
|
+
capabilities = resolve_capabilities(selected, dict(getattr(constitution, "settings", {}) or {}))
|
|
1921
|
+
resolution = resolve_mode(
|
|
1922
|
+
capabilities,
|
|
1923
|
+
config_mode=constitution.get_setting("agent_mode"),
|
|
1924
|
+
)
|
|
1925
|
+
console.print(
|
|
1926
|
+
f"[bold]model[/bold]: {getattr(selected, 'model_id', 'unknown')} "
|
|
1927
|
+
f"(supports_refactor={getattr(selected, 'supports_refactor', True)})"
|
|
1928
|
+
)
|
|
1929
|
+
console.print(
|
|
1930
|
+
f"[bold]capabilities[/bold]: supports_tools={capabilities.supports_tools} "
|
|
1931
|
+
f"context_window={capabilities.context_window} "
|
|
1932
|
+
f"max_output_tokens={capabilities.max_output_tokens} "
|
|
1933
|
+
f"[dim](source={capabilities.source})[/dim]"
|
|
1934
|
+
)
|
|
1935
|
+
budget = compute_budget(
|
|
1936
|
+
capabilities,
|
|
1937
|
+
ratio=float(constitution.get_setting("context_budget_ratio", 0.55) or 0.55),
|
|
1938
|
+
)
|
|
1939
|
+
console.print(
|
|
1940
|
+
f"[bold]execution mode[/bold]: {resolution.mode} "
|
|
1941
|
+
f"[dim](requested={resolution.requested}, source={resolution.source})[/dim]"
|
|
1942
|
+
)
|
|
1943
|
+
console.print(
|
|
1944
|
+
f"[bold]context budget[/bold]: input={budget.input_budget_tokens} tokens, "
|
|
1945
|
+
f"reserved_output={budget.reserved_output_tokens} tokens"
|
|
1946
|
+
)
|
|
1947
|
+
|
|
1948
|
+
_report_intelligence(project_root)
|
|
1949
|
+
|
|
1950
|
+
if skip_ping:
|
|
1951
|
+
console.print("[dim]Skipping provider live ping (--skip-ping).[/dim]")
|
|
1952
|
+
return
|
|
1953
|
+
|
|
1954
|
+
try:
|
|
1955
|
+
_ = selected.review(
|
|
1956
|
+
[{"path": "__doctor__.txt", "language": "text", "chunk_text": "doctor ping"}],
|
|
1957
|
+
constitution,
|
|
1958
|
+
1,
|
|
1959
|
+
)
|
|
1960
|
+
except Exception as exc:
|
|
1961
|
+
console.print(f"[red]provider ping failed[/red]: {exc}")
|
|
1962
|
+
raise typer.Exit(code=1) from exc
|
|
1963
|
+
console.print("[green]provider ping[/green]: OK")
|
|
1964
|
+
|
|
1965
|
+
|
|
1966
|
+
def config() -> None:
|
|
1967
|
+
"""Show resolved settings from refactor.config and environment interpolation."""
|
|
1968
|
+
files = find_project_files()
|
|
1969
|
+
if not files:
|
|
1970
|
+
console.print(
|
|
1971
|
+
f"[yellow]Missing {CONSTITUTION_FILENAME} or {CONFIG_FILENAME}.[/yellow] "
|
|
1972
|
+
"Run `refactor init` first."
|
|
1973
|
+
)
|
|
1974
|
+
raise typer.Exit(code=1)
|
|
1975
|
+
consti_path, config_path = files
|
|
1976
|
+
constitution = load_constitution(consti_path, config_path=config_path)
|
|
1977
|
+
parsed_config = load_config(config_path)
|
|
1978
|
+
console.print(f"[bold]constitution[/bold]: {consti_path}")
|
|
1979
|
+
console.print(f"[bold]config[/bold]: {config_path}")
|
|
1980
|
+
safe_settings = dict(parsed_config.settings)
|
|
1981
|
+
if "developer_key" in safe_settings:
|
|
1982
|
+
safe_settings["developer_key"] = "<set>" if safe_settings["developer_key"] else "<empty>"
|
|
1983
|
+
for key, value in safe_settings.items():
|
|
1984
|
+
console.print(f" {key}: {value}")
|
|
1985
|
+
|
|
1986
|
+
|
|
1987
|
+
def _report_intelligence(project_root: Path) -> None:
|
|
1988
|
+
"""Report Tree-sitter grammars, LSP servers, and last graph build (R10)."""
|
|
1989
|
+
try:
|
|
1990
|
+
from refactor_core.codeintel.parsing import available_languages, tree_sitter_available
|
|
1991
|
+
from refactor_core.codeintel.lsp import detect_servers
|
|
1992
|
+
from refactor_core.store import read_symbol_graph
|
|
1993
|
+
except Exception as exc: # codeintel optional
|
|
1994
|
+
console.print(f"[dim]code intelligence unavailable: {exc}[/dim]")
|
|
1995
|
+
return
|
|
1996
|
+
|
|
1997
|
+
ts = tree_sitter_available()
|
|
1998
|
+
ts_color = "green" if ts else "yellow"
|
|
1999
|
+
console.print(
|
|
2000
|
+
f"[{ts_color}]tree-sitter[/{ts_color}]: "
|
|
2001
|
+
f"{'available' if ts else 'not installed (byte/line fallback)'}; "
|
|
2002
|
+
f"languages={', '.join(available_languages())}"
|
|
2003
|
+
)
|
|
2004
|
+
|
|
2005
|
+
servers = detect_servers()
|
|
2006
|
+
present = sorted({v['name'] for v in servers.values() if v['present']})
|
|
2007
|
+
missing = sorted({v['name'] for v in servers.values() if not v['present']})
|
|
2008
|
+
console.print(f"[bold]lsp servers[/bold]: present={', '.join(present) or '(none)'}")
|
|
2009
|
+
if missing:
|
|
2010
|
+
console.print(f" [dim]missing: {', '.join(missing)}[/dim]")
|
|
2011
|
+
|
|
2012
|
+
graph = read_symbol_graph(project_root)
|
|
2013
|
+
if graph:
|
|
2014
|
+
symbols = len(graph.get("symbols", []))
|
|
2015
|
+
edges = len(graph.get("edges", []))
|
|
2016
|
+
files = len(graph.get("files", {}))
|
|
2017
|
+
console.print(
|
|
2018
|
+
f"[bold]symbol graph[/bold]: {files} file(s), {symbols} symbol(s), {edges} edge(s)"
|
|
2019
|
+
)
|
|
2020
|
+
else:
|
|
2021
|
+
console.print("[dim]symbol graph: not built yet (runs build it incrementally)[/dim]")
|
|
2022
|
+
|
|
2023
|
+
|
|
2024
|
+
def _report_sandbox(project_root: Path, config) -> None:
|
|
2025
|
+
"""Report local sandbox runtime state, latest preflight, and install history."""
|
|
2026
|
+
settings = dict(getattr(config, "settings", {}) or {})
|
|
2027
|
+
sandbox_cfg = settings.get("sandbox", {})
|
|
2028
|
+
if not isinstance(sandbox_cfg, dict):
|
|
2029
|
+
sandbox_cfg = {}
|
|
2030
|
+
enabled = True
|
|
2031
|
+
runtime_pref = str(sandbox_cfg.get("runtime", "podman") or "podman")
|
|
2032
|
+
image = str(sandbox_cfg.get("image") or SANDBOX_DEFAULT_IMAGE)
|
|
2033
|
+
resolved_runtime, reason = resolve_runtime(runtime_pref)
|
|
2034
|
+
|
|
2035
|
+
console.print(f"[bold]project[/bold]: {project_root}")
|
|
2036
|
+
console.print(f"[bold]sandbox enabled[/bold]: {enabled} [dim](mandatory for local runs)[/dim]")
|
|
2037
|
+
console.print(f"[bold]runtime pref[/bold]: {runtime_pref}")
|
|
2038
|
+
if resolved_runtime:
|
|
2039
|
+
console.print(f"[green]runtime[/green]: {resolved_runtime}")
|
|
2040
|
+
else:
|
|
2041
|
+
console.print(f"[red]runtime[/red]: unavailable ({reason})")
|
|
2042
|
+
console.print(f"[bold]image[/bold]: {image}")
|
|
2043
|
+
|
|
2044
|
+
state = read_sandbox_state(project_root)
|
|
2045
|
+
if not state:
|
|
2046
|
+
console.print("[dim]sandbox state: not initialized (run `refactor start`).[/dim]")
|
|
2047
|
+
else:
|
|
2048
|
+
console.print(
|
|
2049
|
+
f"[bold]sandbox state[/bold]: status={state.get('status', '?')} "
|
|
2050
|
+
f"container={state.get('container_name', '?')} "
|
|
2051
|
+
f"updated_at={state.get('updated_at', '?')}"
|
|
2052
|
+
)
|
|
2053
|
+
|
|
2054
|
+
preflight = read_sandbox_preflight(project_root)
|
|
2055
|
+
if not preflight:
|
|
2056
|
+
console.print("[dim]preflight: none recorded yet.[/dim]")
|
|
2057
|
+
else:
|
|
2058
|
+
status = str(preflight.get("status", "unknown"))
|
|
2059
|
+
color = "green" if status == "passed" else "red"
|
|
2060
|
+
console.print(
|
|
2061
|
+
f"[bold]preflight[/bold]: [{color}]{status}[/{color}] "
|
|
2062
|
+
f"reason={preflight.get('reason', '?')} "
|
|
2063
|
+
f"cmd={preflight.get('test_command', '(none)')}"
|
|
2064
|
+
)
|
|
2065
|
+
|
|
2066
|
+
installs = read_sandbox_install_history(project_root)
|
|
2067
|
+
if not installs:
|
|
2068
|
+
console.print("[dim]install history: none.[/dim]")
|
|
2069
|
+
return
|
|
2070
|
+
latest = installs[-1]
|
|
2071
|
+
console.print(
|
|
2072
|
+
f"[bold]install history[/bold]: {len(installs)} entr{'y' if len(installs) == 1 else 'ies'} "
|
|
2073
|
+
f"(latest at {latest.get('at', '?')})"
|
|
2074
|
+
)
|
|
2075
|
+
if isinstance(latest.get("plan"), list):
|
|
2076
|
+
console.print(f" [dim]latest plan steps: {len(latest.get('plan', []))}[/dim]")
|
|
2077
|
+
if isinstance(latest.get("results"), list):
|
|
2078
|
+
failures = sum(1 for r in latest.get("results", []) if not r.get("ok"))
|
|
2079
|
+
console.print(f" [dim]latest command results: {len(latest.get('results', []))} (failures={failures})[/dim]")
|
|
2080
|
+
|
|
2081
|
+
|
|
2082
|
+
def _maybe_build_graph(project_root: Path, config) -> None:
|
|
2083
|
+
"""Build + persist the symbol graph when intelligence.graph is enabled."""
|
|
2084
|
+
settings = dict(getattr(config, "settings", {}) or {})
|
|
2085
|
+
intel = settings.get("intelligence", {})
|
|
2086
|
+
if not isinstance(intel, dict) or not intel.get("graph", True) or not intel.get("enabled", True):
|
|
2087
|
+
return
|
|
2088
|
+
try:
|
|
2089
|
+
from refactor_core.codeintel.graph import build_symbol_graph
|
|
2090
|
+
from refactor_core.codeintel.lsp import LspManager
|
|
2091
|
+
from refactor_core.store import write_symbol_graph
|
|
2092
|
+
|
|
2093
|
+
excludes = set(settings.get("exclude", []) or [])
|
|
2094
|
+
lsp_mode = str(intel.get("lsp", "auto"))
|
|
2095
|
+
lsp_manager = LspManager(project_root, mode=lsp_mode) if lsp_mode != "off" else None
|
|
2096
|
+
graph = build_symbol_graph(project_root, ".", excludes=excludes, lsp_manager=lsp_manager)
|
|
2097
|
+
write_symbol_graph(project_root, graph.to_dict())
|
|
2098
|
+
except Exception:
|
|
2099
|
+
pass # graph is best-effort; never block a run
|
|
2100
|
+
|
|
2101
|
+
|
|
2102
|
+
def _print_rr_summary(artifact) -> None:
|
|
2103
|
+
"""Per-RR outcome table from the RR run's trace summary."""
|
|
2104
|
+
trace = getattr(artifact, "agent_trace_summary", {}) or {}
|
|
2105
|
+
outcomes = trace.get("outcomes", []) or []
|
|
2106
|
+
if not outcomes:
|
|
2107
|
+
console.print("[dim]No refactor requests processed.[/dim]")
|
|
2108
|
+
return
|
|
2109
|
+
table = Table(title="Refactor request outcomes", show_lines=False)
|
|
2110
|
+
table.add_column("issue")
|
|
2111
|
+
table.add_column("outcome", no_wrap=True)
|
|
2112
|
+
table.add_column("attempts", no_wrap=True)
|
|
2113
|
+
color = {"applied": "green", "denied": "yellow", "failed": "red"}
|
|
2114
|
+
for o in outcomes:
|
|
2115
|
+
outcome = str(o.get("outcome", "?"))
|
|
2116
|
+
table.add_row(
|
|
2117
|
+
str(o.get("location", "?")),
|
|
2118
|
+
f"[{color.get(outcome, 'white')}]{outcome}[/{color.get(outcome, 'white')}]",
|
|
2119
|
+
str(o.get("attempts", 0)),
|
|
2120
|
+
)
|
|
2121
|
+
console.print(table)
|
|
2122
|
+
console.print(
|
|
2123
|
+
f"[dim]applied={trace.get('applied', 0)} denied={trace.get('denied', 0)} "
|
|
2124
|
+
f"failed={trace.get('failed', 0)}[/dim]"
|
|
2125
|
+
)
|
|
2126
|
+
|
|
2127
|
+
|
|
2128
|
+
def _print_patches(patches) -> None:
|
|
2129
|
+
if not patches:
|
|
2130
|
+
return
|
|
2131
|
+
table = Table(title="Proposed patches", show_lines=False)
|
|
2132
|
+
table.add_column("group")
|
|
2133
|
+
table.add_column("risk", no_wrap=True)
|
|
2134
|
+
table.add_column("files")
|
|
2135
|
+
for patch in patches:
|
|
2136
|
+
files = ", ".join(patch.metadata.get("affected_files", [])) if patch.metadata else ""
|
|
2137
|
+
table.add_row(patch.group_name, str(patch.risk_score), files)
|
|
2138
|
+
console.print(table)
|
|
2139
|
+
|
|
2140
|
+
|
|
2141
|
+
def _print_findings(findings) -> None:
|
|
2142
|
+
if not findings:
|
|
2143
|
+
console.print("[green]No findings.[/green]")
|
|
2144
|
+
return
|
|
2145
|
+
table = Table(title="Findings", show_lines=False)
|
|
2146
|
+
table.add_column("severity", no_wrap=True)
|
|
2147
|
+
table.add_column("category")
|
|
2148
|
+
table.add_column("file:symbol")
|
|
2149
|
+
table.add_column("recommendation")
|
|
2150
|
+
for finding in findings:
|
|
2151
|
+
style = _SEVERITY_STYLE.get(finding.severity, "white")
|
|
2152
|
+
location = finding.file_path + (f":{finding.symbol}" if finding.symbol else "")
|
|
2153
|
+
table.add_row(
|
|
2154
|
+
f"[{style}]{finding.severity}[/{style}]",
|
|
2155
|
+
finding.category,
|
|
2156
|
+
location,
|
|
2157
|
+
finding.recommendation,
|
|
2158
|
+
)
|
|
2159
|
+
console.print(table)
|