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.
@@ -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)